feat: Pipeline↔Anfragen integration + Compare→Pipeline + KI stage detection

Navigation: Deal Pipeline moved after Vergleich (before Anfragen)

Compare → Pipeline:
- Bookmark icon per column header; BookmarkCheck when already in pipeline
- Passes propertyId, propertyAddress, area/rent labels on save

Pipeline cards now unit-level:
- propertyAddress shown with MapPin on every card
- Chat icon (MessageSquare) on cards with linked inquiry → navigates to /demand/anfragen?inquiry=xxx
- Detail panel: Chat chip links to specific inquiry thread, propertyAddress displayed

Anfragen → Pipeline KI detection:
- Keyword scan on every sent message (besichtigung → VISITED, mietvertrag → NEGOTIATION, unterschrieben → CLOSED_WON)
- Only advances stage, never goes back
- Purple KI alert banner with direct Pipeline link, auto-dismisses after 6s
- Pipeline badge in inquiry list + stage chip in chat header with nav link
- URL param ?inquiry=xxx pre-selects inquiry (used from Pipeline chat button)

Domain: PipelineItem gains propertyId, unitId, propertyAddress, inquiryId
Mock data: pl-001/pl-002/pl-004 linked to inq-001/inq-005/inq-004

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-22 21:42:21 +02:00
parent a002597f5b
commit 6a6ff7f2e2
8 changed files with 505 additions and 233 deletions
+50 -9
View File
@@ -1,5 +1,6 @@
import { Box, Chip, IconButton, Tooltip, Typography } from '@mui/material'
import { X, AlertTriangle } from 'lucide-react'
import { X, AlertTriangle, Bookmark, BookmarkCheck } from 'lucide-react'
import { usePipelineStore } from '../../stores/pipelineStore'
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
const TYPE_META: Record<string, { label: string; color: string }> = {
@@ -17,30 +18,70 @@ interface Props {
}
export function CompareColumnHeader({ item, onRemove }: Props) {
const { items: pipelineItems, openSavedDialog } = usePipelineStore()
const meta = TYPE_META[item.resultType] ?? { label: item.resultType, color: '#64748b' }
const prop = item.resultType !== 'FUTURE_AVAILABILITY' ? (item as any).property : null
const sig = item.resultType === 'FUTURE_AVAILABILITY' ? (item as any).signal : null
const title = prop?.title ?? sig?.companyName ?? sig?.locationHint ?? ''
const subtitle = prop?.location?.city ?? sig?.locationHint ?? ''
const title = prop?.title ?? sig?.companyName ?? sig?.locationHint ?? ''
const subtitle = prop?.location?.city ?? sig?.locationHint ?? ''
const district = prop?.location?.district
const availability = prop?.availabilityDate ?? (sig ? `~${sig.timeHorizonMonths} Monate` : null)
const confidence = Math.round(item.match.confidenceLevel * 100)
const source = prop?.sourceLabel ?? sig?.source?.type ?? ''
const confidence = Math.round(item.match.confidenceLevel * 100)
const source = prop?.sourceLabel ?? sig?.source?.type ?? ''
const isInPipeline = pipelineItems.some(
pi => pi.id === item.matchId || (prop?.id && pi.propertyId === prop.id)
)
function handleSave() {
if (isInPipeline) return
openSavedDialog({
resultId: item.matchId,
resultType: item.resultType,
title,
location: prop?.location?.city ?? sig?.locationHint,
matchScore: item.matchScore,
propertyId: prop?.id,
propertyAddress: prop ? `${title}, ${subtitle}${district ? `, ${district}` : ''}` : undefined,
areaLabel: prop?.areaSqm ? `${prop.areaSqm.toLocaleString('de-CH')}` : undefined,
rentLabel: prop?.rentPricePerSqm ? `CHF ${prop.rentPricePerSqm}/m²` : undefined,
availabilityLabel: prop?.availabilityDate ?? undefined,
})
}
return (
<Box sx={{ p: 0.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
<Chip label={meta.label} size="small" sx={{ bgcolor: meta.color, color: 'white', fontWeight: 600, fontSize: 10 }} />
<IconButton size="small" onClick={onRemove} sx={{ p: 0.25, ml: 1 }}>
<X size={14} />
</IconButton>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<Tooltip title={isInPipeline ? 'Bereits in Pipeline' : 'In Pipeline merken'}>
<IconButton
size="small"
onClick={handleSave}
sx={{
p: 0.25,
color: isInPipeline ? '#1a7a4a' : '#94a3b8',
'&:hover': { color: isInPipeline ? '#1a7a4a' : '#1e3a5f' },
}}
>
{isInPipeline
? <BookmarkCheck size={14} />
: <Bookmark size={14} />}
</IconButton>
</Tooltip>
<IconButton size="small" onClick={onRemove} sx={{ p: 0.25, ml: 0.25 }}>
<X size={14} />
</IconButton>
</Box>
</Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.25, lineHeight: 1.3 }}>
{title}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
{subtitle}
{subtitle}{district ? `, ${district}` : ''}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5, mb: 0.5 }}>
+1 -1
View File
@@ -103,8 +103,8 @@ const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
{ path: '/demand/ai-search', label: 'Flächensuche', icon: Search },
{ path: '/demand/results', label: 'Ergebnisse', icon: List },
{ path: '/demand/compare', label: 'Vergleich', icon: Columns2 },
{ path: '/demand/anfragen', label: 'Anfragen', icon: MessageSquare },
{ path: '/demand/pipeline', label: 'Deal Pipeline', icon: Kanban },
{ path: '/demand/anfragen', label: 'Anfragen', icon: MessageSquare },
],
},
}
+14 -7
View File
@@ -33,13 +33,20 @@ export function UnifiedResultCard({ result, view = 'list' }: Props) {
label: 'Merken',
actionType: 'SAVE_SHORTLIST',
variant: 'secondary',
onClick: () => openSavedDialog({
resultId: result.matchId,
resultType: result.resultType,
title: getResultTitle(result),
matchScore: result.matchScore,
location: result.resultType !== 'FUTURE_AVAILABILITY' ? result.property.location?.city : undefined,
}),
onClick: () => {
const prop = result.resultType !== 'FUTURE_AVAILABILITY' ? (result as any).property : null
openSavedDialog({
resultId: result.matchId,
resultType: result.resultType,
title: getResultTitle(result),
matchScore: result.matchScore,
location: prop?.location?.city,
propertyId: prop?.id,
propertyAddress: prop ? `${prop.title}, ${prop.location?.city ?? ''}` : undefined,
areaLabel: prop?.areaSqm ? `${prop.areaSqm.toLocaleString('de-CH')}` : undefined,
rentLabel: prop?.rentPricePerSqm ? `CHF ${prop.rentPricePerSqm}/m²` : undefined,
})
},
},
{
id: 'compare',