From 72e4f0890053b72791b405888804356f1972b1e0 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Fri, 22 May 2026 22:11:29 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Compare=E2=86=92Pipeline=20flow,=20pipel?= =?UTF-8?q?ine=20card=20navigation,=20remove=20floating=20AI=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../compare/CompareColumnHeader.tsx | 89 ++++++++---- src/components/layout/AppShell.tsx | 42 ++++-- src/domain/pipeline.ts | 2 + src/mock-data/pipelineItems.ts | 10 ++ src/pages/demand/Compare.tsx | 3 + src/pages/demand/Pipeline.tsx | 132 +++++++++++++----- src/stores/pipelineStore.ts | 1 + 7 files changed, 201 insertions(+), 78 deletions(-) diff --git a/src/components/compare/CompareColumnHeader.tsx b/src/components/compare/CompareColumnHeader.tsx index d055827..9870707 100644 --- a/src/components/compare/CompareColumnHeader.tsx +++ b/src/components/compare/CompareColumnHeader.tsx @@ -1,5 +1,6 @@ -import { Box, Chip, IconButton, Tooltip, Typography } from '@mui/material' -import { X, AlertTriangle, Bookmark, BookmarkCheck } from 'lucide-react' +import { Box, Button, Chip, IconButton, Tooltip, Typography } from '@mui/material' +import { X, AlertTriangle, BookmarkCheck, ExternalLink, Kanban } from 'lucide-react' +import { useNavigate } from 'react-router' import { usePipelineStore } from '../../stores/pipelineStore' import type { UnifiedMatchResult } from '../../domain/unifiedResult' @@ -18,6 +19,7 @@ interface Props { } export function CompareColumnHeader({ item, onRemove }: Props) { + const navigate = useNavigate() const { items: pipelineItems, openSavedDialog } = usePipelineStore() const meta = TYPE_META[item.resultType] ?? { label: item.resultType, color: '#64748b' } @@ -53,45 +55,43 @@ export function CompareColumnHeader({ item, onRemove }: Props) { return ( - + + {/* Row 1: type chip + remove */} + - - - - {isInPipeline - ? - : } - - - - + + + - + - + {/* Row 2: title (clickable) */} + navigate(`/demand/results/${item.matchId}`)} + > {title} - + {subtitle}{district ? `, ${district}` : ''} - + {/* Row 3: score */} + {item.matchScore} /100 - + {/* Row 4: meta chips */} + {item.resultType === 'FUTURE_AVAILABILITY' && sig && ( - + Probabilistisches Signal @@ -122,6 +122,41 @@ export function CompareColumnHeader({ item, onRemove }: Props) { )} + + {/* Row 5: action buttons */} + + + {isInPipeline ? ( + + ) : ( + + )} + + ) } diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 6b842fb..18c8cca 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -46,10 +46,10 @@ import { OrganizationContextBadge } from './OrganizationContextBadge' import { UserMenu } from './UserMenu' import { NotificationButton } from './NotificationButton' import { RightContextPanel } from './RightContextPanel' -import { CompareTray } from './CompareTray' -import { GlobalAIAssistantDrawer, GlobalAIAssistantButton } from '../assistant' +import { GlobalAIAssistantDrawer } from '../assistant' import { useAssistantStore } from '../../stores/assistantStore' import { ToastProvider } from '../ui' +import { useCompareStore } from '../../stores/compareStore' // --------------------------------------------------------------------------- // Types @@ -181,6 +181,7 @@ function Sidebar({ orgName, }: SidebarProps) { const config = WORKSPACE_CONFIG[activeWorkspace] + const compareCount = useCompareStore(s => s.compareItems.length) const width = collapsed ? 60 : 264 const visibleWorkspaces = WORKSPACE_ORDER.filter((ws) => allowedWorkspaces.includes(ws)) @@ -337,17 +338,30 @@ function Sidebar({ > {!collapsed && ( - - {item.label} - + + + {item.label} + + {item.path === '/demand/compare' && compareCount > 0 && ( + + {compareCount} + + )} + )} )} @@ -605,9 +619,7 @@ export function AppShell() { - - diff --git a/src/domain/pipeline.ts b/src/domain/pipeline.ts index aed508f..97bf10d 100644 --- a/src/domain/pipeline.ts +++ b/src/domain/pipeline.ts @@ -7,6 +7,8 @@ export interface PipelineItem { matchScore: number resultType: 'VERIFIED_PORTFOLIO' | 'EXTERNAL_MARKET' | 'MAISON_WORK' | 'FUTURE_AVAILABILITY' stage: PipelineStage + // Source match reference — used for navigating back to the detail page + matchId?: string // Property / unit reference — always set for unit-level tracking propertyId?: string unitId?: string diff --git a/src/mock-data/pipelineItems.ts b/src/mock-data/pipelineItems.ts index c7f8e00..5110c4b 100644 --- a/src/mock-data/pipelineItems.ts +++ b/src/mock-data/pipelineItems.ts @@ -36,6 +36,8 @@ export const mockPipelineItems: PipelineItem[] = [ }, { id: 'pl-003', + matchId: 'match-005', + propertyId: 'prop-003', propertyAddress: 'Binzstrasse 23, Zürich-Binz', title: 'Büro Binzstrasse 23', location: 'Zürich, Binz', @@ -65,6 +67,8 @@ export const mockPipelineItems: PipelineItem[] = [ }, { id: 'pl-005', + matchId: 'match-060', + propertyId: 'prop-042', propertyAddress: 'Technopark, Zürich-West', title: 'DataCloud Systems AG', location: 'Zürich-West / Technopark', @@ -78,6 +82,8 @@ export const mockPipelineItems: PipelineItem[] = [ }, { id: 'pl-006', + matchId: 'match-061', + propertyId: 'prop-013', propertyAddress: 'Wankdorf Business Park, Bern', title: 'Neubau Wankdorf Business', location: 'Bern, Wankdorf', @@ -91,6 +97,8 @@ export const mockPipelineItems: PipelineItem[] = [ }, { id: 'pl-007', + matchId: 'match-015', + propertyId: 'prop-015', propertyAddress: 'Stadthaus-Gasse 1, Bern Innenstadt', title: 'Bürofläche Stadthaus Bern', location: 'Bern Innenstadt', @@ -107,6 +115,8 @@ export const mockPipelineItems: PipelineItem[] = [ }, { id: 'pl-008', + matchId: 'match-002', + propertyId: 'prop-016', propertyAddress: 'Güterstrasse 22, Basel', title: 'Gewerbe Güterstrasse Basel', location: 'Basel', diff --git a/src/pages/demand/Compare.tsx b/src/pages/demand/Compare.tsx index 72e5cb0..62db947 100644 --- a/src/pages/demand/Compare.tsx +++ b/src/pages/demand/Compare.tsx @@ -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 ( + + {/* Page header */} diff --git a/src/pages/demand/Pipeline.tsx b/src/pages/demand/Pipeline.tsx index ececc34..8aa3580 100644 --- a/src/pages/demand/Pipeline.tsx +++ b/src/pages/demand/Pipeline.tsx @@ -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 ( - !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 })} > - - - {item.title} - - - {item.inquiryId && onChatClick && !isDragOverlay && ( - - - - - - )} - - {item.matchScore}% - + {/* Row 1: Score + type chip + chat icon */} + + + + + + {!isDragOverlay && ( + + {item.inquiryId && onChatClick && ( + + + + + + )} + {path && ( + + { e.stopPropagation(); navigate(path) }} + sx={{ p: 0.25, color: '#94a3b8', '&:hover': { color: '#1e3a5f', bgcolor: '#eff6ff' } }} + > + + + + )} + + )} + + {/* Row 2: Title */} + + {item.title} + + + {/* Row 3: Location */} - - + + {item.propertyAddress ?? item.location} - - - {item.areaLabel && {item.areaLabel}} - {item.rentLabel && {item.rentLabel}} - + + {/* Row 4: Area + rent */} + {(item.areaLabel || item.rentLabel) && ( + + {item.areaLabel && ( + {item.areaLabel} + )} + {item.areaLabel && item.rentLabel && ( + + )} + {item.rentLabel && ( + {item.rentLabel} + )} + + )} + + {/* Row 5: Notes preview */} {item.notes && ( - + {item.notes} )} - + ) } @@ -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 {item.title} {item.location} - + {item.matchScore}% + {path && ( + + navigate(path)} sx={{ color: '#64748b', '&:hover': { color: '#1e3a5f' } }}> + + + + )} diff --git a/src/stores/pipelineStore.ts b/src/stores/pipelineStore.ts index d81de9c..a8d52fd 100644 --- a/src/stores/pipelineStore.ts +++ b/src/stores/pipelineStore.ts @@ -49,6 +49,7 @@ export const usePipelineStore = create((set, get) => ({ if (!alreadyExists) { const newItem: PipelineItem = { id: pendingItem.resultId, + matchId: pendingItem.resultId, title: pendingItem.title, location: pendingItem.location ?? '–', matchScore: pendingItem.matchScore,