fix: Compare→Pipeline flow, pipeline card navigation, remove floating AI button

- Remove CompareTray fixed bottom bar; replace with compare count badge on Vergleich nav item
- Pipeline cards redesigned to match search result card visual style (MatchScoreDisplay, type chip, stage chip, MapPin layout)
- CompareColumnHeader: redesigned with proper Details/Merken action buttons, clickable title
- AddToPipelineDialog now mounted on Compare page (was missing — bookmark had no effect)
- Pipeline card ExternalLink icon navigates to property detail page; detailPath prefers stable propertyId over volatile session matchId
- Add matchId field to PipelineItem domain; pipelineStore stores it on save
- All 8 mock pipeline items now have propertyId for reliable cross-session navigation
- Remove GlobalAIAssistantButton floating overlay (was blocking form submissions and clicks)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-22 22:11:29 +02:00
parent 6a6ff7f2e2
commit 72e4f08900
7 changed files with 201 additions and 78 deletions
+3
View File
@@ -29,6 +29,7 @@ import {
MissingDataCell,
AICompareSummary,
} from '../../components/compare'
import { AddToPipelineDialog } from '../../components/shortlist'
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
import type { VerifiedPortfolioResult, ExternalMarketResult, FutureAvailabilityResult } from '../../domain/unifiedResult'
@@ -204,6 +205,8 @@ export default function Compare() {
return (
<Box>
<AddToPipelineDialog />
{/* Page header */}
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
+96 -36
View File
@@ -1,7 +1,7 @@
import { useState } from 'react'
import { useNavigate } from 'react-router'
import {
Box, Typography, Chip, Paper, Button, IconButton, TextField, Divider, Tooltip,
Box, Typography, Chip, Paper, Button, IconButton, TextField, Divider, Tooltip, Card,
} from '@mui/material'
import {
DndContext, DragOverlay, PointerSensor, useSensor, useSensors,
@@ -9,9 +9,10 @@ import {
} from '@dnd-kit/core'
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
import { CSS } from '@dnd-kit/utilities'
import { X, Sparkles, FileText, StickyNote, ChevronRight, TrendingUp, AlertTriangle, CheckCircle, MessageSquare, MapPin } from 'lucide-react'
import { X, Sparkles, FileText, StickyNote, ChevronRight, TrendingUp, AlertTriangle, CheckCircle, MessageSquare, MapPin, ExternalLink } from 'lucide-react'
import { usePipelineStore } from '../../stores/pipelineStore'
import { AddToPipelineDialog } from '../../components/shortlist'
import { MatchScoreDisplay } from '../../components/match-card/MatchScoreDisplay'
import type { PipelineItem, PipelineStage } from '../../domain/pipeline'
// ── Stage config ──────────────────────────────────────────────────────────────
@@ -64,6 +65,15 @@ function scoreColor(score: number) {
return score >= 80 ? '#1a7a4a' : score >= 65 ? '#d97706' : '#c0392b'
}
function detailPath(item: PipelineItem): string | null {
// propertyId is always stable across sessions — prefer it
if (item.propertyId) return `/demand/property/${item.propertyId}`
// matchId / UUID only works in the same session (matchStore is ephemeral)
if (item.matchId) return `/demand/results/${item.matchId}`
if (item.id.startsWith('match-')) return `/demand/results/${item.id}`
return null
}
function getKiInsight(item: PipelineItem): { summary: string; positives: string[]; risks: string[] } {
if (item.stage === 'SAVED') return {
summary: `Merkliste-Eintrag mit ${item.matchScore}% Match. Prüfen Sie, ob dieses Objekt qualifiziert werden soll.`,
@@ -116,7 +126,10 @@ function DraggableCard({
isDragOverlay?: boolean
onChatClick?: (e: React.MouseEvent) => void
}) {
const navigate = useNavigate()
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: item.id })
const stageConfig = STAGES.find(s => s.key === item.stage)!
const path = detailPath(item)
const style = !isDragOverlay ? {
transform: CSS.Translate.toString(transform),
@@ -125,20 +138,23 @@ function DraggableCard({
} : undefined
return (
<Paper
<Card
ref={!isDragOverlay ? setNodeRef : undefined}
style={style}
elevation={isDragOverlay ? 6 : 0}
onClick={() => !isDragging && onSelect(item)}
sx={{
p: 1.5,
borderRadius: 1.5,
border: isSelected && !isDragOverlay ? '2px solid #1e3a5f' : '1px solid #e2e8f0',
border: isSelected && !isDragOverlay
? '2px solid #1e3a5f'
: isDragOverlay
? '2px solid transparent'
: '2px solid transparent',
cursor: isDragOverlay ? 'grabbing' : 'grab',
bgcolor: isDragOverlay ? 'white' : isSelected ? '#eff6ff' : 'white',
boxShadow: isDragOverlay ? '0 8px 24px rgba(0,0,0,0.18)' : undefined,
boxShadow: isDragOverlay ? '0 8px 24px rgba(0,0,0,0.18)' : '0 1px 3px rgba(0,0,0,0.08)',
'&:hover': isDragOverlay ? {} : {
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
borderColor: isSelected ? '#1e3a5f' : '#bfdbfe',
},
transition: isDragOverlay ? undefined : 'box-shadow 0.15s, border-color 0.15s',
@@ -147,44 +163,80 @@ function DraggableCard({
}}
{...(isDragOverlay ? {} : { ...attributes, ...listeners })}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1, mb: 0.375 }}>
<Typography variant="body2" sx={{ fontWeight: 700, flex: 1, minWidth: 0, lineHeight: 1.3 }} noWrap>
{item.title}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, flexShrink: 0 }}>
{item.inquiryId && onChatClick && !isDragOverlay && (
<Tooltip title="Chat öffnen">
<IconButton size="small" onClick={onChatClick} sx={{ p: 0.25, color: '#1e3a5f', '&:hover': { bgcolor: '#eff6ff' } }}>
<MessageSquare size={13} />
</IconButton>
</Tooltip>
)}
<Typography sx={{ fontWeight: 900, fontSize: '0.9rem', color: scoreColor(item.matchScore) }}>
{item.matchScore}%
</Typography>
{/* Row 1: Score + type chip + chat icon */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 0.5, mb: 0.75 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flex: 1, minWidth: 0, flexWrap: 'wrap' }}>
<MatchScoreDisplay score={item.matchScore} size="sm" />
<Chip
size="small"
label={RESULT_TYPE_LABEL[item.resultType] ?? item.resultType}
sx={{ bgcolor: RESULT_TYPE_COLOR[item.resultType] ?? '#475569', color: 'white', fontSize: 10, height: 20, fontWeight: 600 }}
/>
<Chip
size="small"
label={stageConfig.label}
sx={{ bgcolor: stageConfig.bgColor, color: stageConfig.color, fontSize: 10, height: 20, fontWeight: 600 }}
/>
</Box>
{!isDragOverlay && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.125, flexShrink: 0 }}>
{item.inquiryId && onChatClick && (
<Tooltip title="Chat öffnen">
<IconButton size="small" onClick={onChatClick} sx={{ p: 0.25, color: '#1e3a5f', '&:hover': { bgcolor: '#eff6ff' } }}>
<MessageSquare size={13} />
</IconButton>
</Tooltip>
)}
{path && (
<Tooltip title="Objekt öffnen">
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); navigate(path) }}
sx={{ p: 0.25, color: '#94a3b8', '&:hover': { color: '#1e3a5f', bgcolor: '#eff6ff' } }}
>
<ExternalLink size={13} />
</IconButton>
</Tooltip>
)}
</Box>
)}
</Box>
{/* Row 2: Title */}
<Typography variant="body2" sx={{ fontWeight: 700, lineHeight: 1.3, mb: 0.25 }} noWrap>
{item.title}
</Typography>
{/* Row 3: Location */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
<MapPin size={10} color="#94a3b8" />
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
<MapPin size={11} color="#64748b" />
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.72rem' }} noWrap>
{item.propertyAddress ?? item.location}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
<Chip
size="small"
label={RESULT_TYPE_LABEL[item.resultType] ?? item.resultType}
sx={{ bgcolor: RESULT_TYPE_COLOR[item.resultType] ?? '#475569', color: 'white', fontSize: 10, height: 18 }}
/>
{item.areaLabel && <Typography variant="caption" sx={{ color: '#475569' }}>{item.areaLabel}</Typography>}
{item.rentLabel && <Typography variant="caption" sx={{ color: '#475569' }}>{item.rentLabel}</Typography>}
</Box>
{/* Row 4: Area + rent */}
{(item.areaLabel || item.rentLabel) && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
{item.areaLabel && (
<Typography variant="caption" sx={{ color: '#475569', fontSize: '0.72rem' }}>{item.areaLabel}</Typography>
)}
{item.areaLabel && item.rentLabel && (
<Box sx={{ width: 3, height: 3, borderRadius: '50%', bgcolor: '#cbd5e1' }} />
)}
{item.rentLabel && (
<Typography variant="caption" sx={{ color: '#475569', fontSize: '0.72rem' }}>{item.rentLabel}</Typography>
)}
</Box>
)}
{/* Row 5: Notes preview */}
{item.notes && (
<Typography variant="caption" sx={{ fontStyle: 'italic', color: '#94a3b8', mt: 0.5, display: 'block' }} noWrap>
<Typography variant="caption" sx={{ fontStyle: 'italic', color: '#94a3b8', mt: 0.5, display: 'block', fontSize: '0.7rem' }} noWrap>
{item.notes}
</Typography>
)}
</Paper>
</Card>
)
}
@@ -247,6 +299,7 @@ function DroppableColumn({
function DetailPanel({ item, onClose }: { item: PipelineItem; onClose: () => void }) {
const navigate = useNavigate()
const { moveStage, updateNotes, loseItem } = usePipelineStore()
const path = detailPath(item)
const [notes, setNotes] = useState(item.notes ?? '')
const stageConfig = STAGES.find(s => s.key === item.stage)!
const stageIndex = STAGES.findIndex(s => s.key === item.stage)
@@ -269,10 +322,17 @@ function DetailPanel({ item, onClose }: { item: PipelineItem; onClose: () => voi
<Typography variant="body1" sx={{ fontWeight: 700, lineHeight: 1.3, mb: 0.25 }}>{item.title}</Typography>
<Typography variant="caption" color="text.secondary">{item.location}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, flexShrink: 0 }}>
<Typography sx={{ fontWeight: 900, fontSize: '1.1rem', color: scoreColor(item.matchScore) }}>
{item.matchScore}%
</Typography>
{path && (
<Tooltip title="Vollständige Detailansicht öffnen">
<IconButton size="small" onClick={() => navigate(path)} sx={{ color: '#64748b', '&:hover': { color: '#1e3a5f' } }}>
<ExternalLink size={15} />
</IconButton>
</Tooltip>
)}
<IconButton size="small" onClick={onClose} sx={{ color: '#94a3b8' }}>
<X size={16} />
</IconButton>