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:
@@ -1,5 +1,6 @@
|
|||||||
import { Box, Chip, IconButton, Tooltip, Typography } from '@mui/material'
|
import { Box, Button, Chip, IconButton, Tooltip, Typography } from '@mui/material'
|
||||||
import { X, AlertTriangle, Bookmark, BookmarkCheck } from 'lucide-react'
|
import { X, AlertTriangle, BookmarkCheck, ExternalLink, Kanban } from 'lucide-react'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
import { usePipelineStore } from '../../stores/pipelineStore'
|
import { usePipelineStore } from '../../stores/pipelineStore'
|
||||||
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CompareColumnHeader({ item, onRemove }: Props) {
|
export function CompareColumnHeader({ item, onRemove }: Props) {
|
||||||
|
const navigate = useNavigate()
|
||||||
const { items: pipelineItems, openSavedDialog } = usePipelineStore()
|
const { items: pipelineItems, openSavedDialog } = usePipelineStore()
|
||||||
|
|
||||||
const meta = TYPE_META[item.resultType] ?? { label: item.resultType, color: '#64748b' }
|
const meta = TYPE_META[item.resultType] ?? { label: item.resultType, color: '#64748b' }
|
||||||
@@ -53,45 +55,43 @@ export function CompareColumnHeader({ item, onRemove }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: 0.5 }}>
|
<Box sx={{ p: 0.5 }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
|
|
||||||
|
{/* Row 1: type chip + remove */}
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||||
<Chip label={meta.label} size="small" sx={{ bgcolor: meta.color, color: 'white', fontWeight: 600, fontSize: 10 }} />
|
<Chip label={meta.label} size="small" sx={{ bgcolor: meta.color, color: 'white', fontWeight: 600, fontSize: 10 }} />
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
<Tooltip title="Aus Vergleich entfernen">
|
||||||
<Tooltip title={isInPipeline ? 'Bereits in Pipeline' : 'In Pipeline merken'}>
|
<IconButton size="small" onClick={onRemove} sx={{ p: 0.375, color: '#94a3b8', '&:hover': { color: '#c0392b' } }}>
|
||||||
<IconButton
|
<X size={15} />
|
||||||
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>
|
</IconButton>
|
||||||
</Box>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.25, lineHeight: 1.3 }}>
|
{/* Row 2: title (clickable) */}
|
||||||
|
<Typography
|
||||||
|
variant="subtitle1"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700, lineHeight: 1.3, mb: 0.25,
|
||||||
|
cursor: 'pointer',
|
||||||
|
'&:hover': { color: '#1e3a5f' },
|
||||||
|
}}
|
||||||
|
onClick={() => navigate(`/demand/results/${item.matchId}`)}
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1.25 }}>
|
||||||
{subtitle}{district ? `, ${district}` : ''}
|
{subtitle}{district ? `, ${district}` : ''}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5, mb: 0.5 }}>
|
{/* Row 3: score */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5, mb: 1 }}>
|
||||||
<Typography variant="h4" sx={{ fontWeight: 800, color: SCORE_COLOR(item.matchScore), lineHeight: 1 }}>
|
<Typography variant="h4" sx={{ fontWeight: 800, color: SCORE_COLOR(item.matchScore), lineHeight: 1 }}>
|
||||||
{item.matchScore}
|
{item.matchScore}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">/100</Typography>
|
<Typography variant="caption" color="text.secondary">/100</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 0.5 }}>
|
{/* Row 4: meta chips */}
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mb: 1.25 }}>
|
||||||
<Tooltip title="Konfidenz">
|
<Tooltip title="Konfidenz">
|
||||||
<Chip
|
<Chip
|
||||||
label={`${confidence}% Konfidenz`}
|
label={`${confidence}% Konfidenz`}
|
||||||
@@ -113,7 +113,7 @@ export function CompareColumnHeader({ item, onRemove }: Props) {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{item.resultType === 'FUTURE_AVAILABILITY' && sig && (
|
{item.resultType === 'FUTURE_AVAILABILITY' && sig && (
|
||||||
<Box sx={{ mt: 1, p: 0.75, bgcolor: '#faf5ff', borderRadius: 1, border: '1px solid #e9d5ff' }}>
|
<Box sx={{ mb: 1.25, p: 0.75, bgcolor: '#faf5ff', borderRadius: 1, border: '1px solid #e9d5ff' }}>
|
||||||
<Typography variant="caption" sx={{ color: '#7c3aed', fontWeight: 600, display: 'block' }}>
|
<Typography variant="caption" sx={{ color: '#7c3aed', fontWeight: 600, display: 'block' }}>
|
||||||
Probabilistisches Signal
|
Probabilistisches Signal
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -122,6 +122,41 @@ export function CompareColumnHeader({ item, onRemove }: Props) {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Row 5: action buttons */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.75, pt: 1, borderTop: '1px solid #f1f5f9' }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<ExternalLink size={12} />}
|
||||||
|
onClick={() => navigate(`/demand/results/${item.matchId}`)}
|
||||||
|
sx={{ flex: 1, textTransform: 'none', fontSize: '0.72rem', py: 0.5 }}
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</Button>
|
||||||
|
{isInPipeline ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<BookmarkCheck size={12} />}
|
||||||
|
disabled
|
||||||
|
sx={{ flex: 1, textTransform: 'none', fontSize: '0.72rem', py: 0.5, color: '#1a7a4a', borderColor: '#86efac' }}
|
||||||
|
>
|
||||||
|
In Pipeline
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Kanban size={12} />}
|
||||||
|
onClick={handleSave}
|
||||||
|
sx={{ flex: 1, textTransform: 'none', fontSize: '0.72rem', py: 0.5, bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||||
|
>
|
||||||
|
Merken
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,10 +46,10 @@ import { OrganizationContextBadge } from './OrganizationContextBadge'
|
|||||||
import { UserMenu } from './UserMenu'
|
import { UserMenu } from './UserMenu'
|
||||||
import { NotificationButton } from './NotificationButton'
|
import { NotificationButton } from './NotificationButton'
|
||||||
import { RightContextPanel } from './RightContextPanel'
|
import { RightContextPanel } from './RightContextPanel'
|
||||||
import { CompareTray } from './CompareTray'
|
import { GlobalAIAssistantDrawer } from '../assistant'
|
||||||
import { GlobalAIAssistantDrawer, GlobalAIAssistantButton } from '../assistant'
|
|
||||||
import { useAssistantStore } from '../../stores/assistantStore'
|
import { useAssistantStore } from '../../stores/assistantStore'
|
||||||
import { ToastProvider } from '../ui'
|
import { ToastProvider } from '../ui'
|
||||||
|
import { useCompareStore } from '../../stores/compareStore'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -181,6 +181,7 @@ function Sidebar({
|
|||||||
orgName,
|
orgName,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const config = WORKSPACE_CONFIG[activeWorkspace]
|
const config = WORKSPACE_CONFIG[activeWorkspace]
|
||||||
|
const compareCount = useCompareStore(s => s.compareItems.length)
|
||||||
const width = collapsed ? 60 : 264
|
const width = collapsed ? 60 : 264
|
||||||
const visibleWorkspaces = WORKSPACE_ORDER.filter((ws) => allowedWorkspaces.includes(ws))
|
const visibleWorkspaces = WORKSPACE_ORDER.filter((ws) => allowedWorkspaces.includes(ws))
|
||||||
|
|
||||||
@@ -337,17 +338,30 @@ function Sidebar({
|
|||||||
>
|
>
|
||||||
<Icon size={16} color={isActive ? TEXT_WHITE : TEXT_MUTED} />
|
<Icon size={16} color={isActive ? TEXT_WHITE : TEXT_MUTED} />
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<Typography
|
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
|
||||||
variant="body2"
|
<Typography
|
||||||
sx={{
|
variant="body2"
|
||||||
color: isActive ? TEXT_WHITE : TEXT_MUTED,
|
sx={{
|
||||||
fontWeight: isActive ? 500 : 400,
|
color: isActive ? TEXT_WHITE : TEXT_MUTED,
|
||||||
fontSize: '0.8125rem',
|
fontWeight: isActive ? 500 : 400,
|
||||||
whiteSpace: 'nowrap',
|
fontSize: '0.8125rem',
|
||||||
}}
|
whiteSpace: 'nowrap',
|
||||||
>
|
}}
|
||||||
{item.label}
|
>
|
||||||
</Typography>
|
{item.label}
|
||||||
|
</Typography>
|
||||||
|
{item.path === '/demand/compare' && compareCount > 0 && (
|
||||||
|
<Box sx={{
|
||||||
|
minWidth: 18, height: 18, borderRadius: '9px',
|
||||||
|
bgcolor: '#d97706', color: 'white',
|
||||||
|
fontSize: '0.65rem', fontWeight: 700,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
px: 0.5, flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{compareCount}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -605,9 +619,7 @@ export function AppShell() {
|
|||||||
<RightContextPanel />
|
<RightContextPanel />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<CompareTray />
|
|
||||||
<GlobalAIAssistantDrawer />
|
<GlobalAIAssistantDrawer />
|
||||||
<GlobalAIAssistantButton />
|
|
||||||
<ToastProvider />
|
<ToastProvider />
|
||||||
<WelcomeDialog />
|
<WelcomeDialog />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export interface PipelineItem {
|
|||||||
matchScore: number
|
matchScore: number
|
||||||
resultType: 'VERIFIED_PORTFOLIO' | 'EXTERNAL_MARKET' | 'MAISON_WORK' | 'FUTURE_AVAILABILITY'
|
resultType: 'VERIFIED_PORTFOLIO' | 'EXTERNAL_MARKET' | 'MAISON_WORK' | 'FUTURE_AVAILABILITY'
|
||||||
stage: PipelineStage
|
stage: PipelineStage
|
||||||
|
// Source match reference — used for navigating back to the detail page
|
||||||
|
matchId?: string
|
||||||
// Property / unit reference — always set for unit-level tracking
|
// Property / unit reference — always set for unit-level tracking
|
||||||
propertyId?: string
|
propertyId?: string
|
||||||
unitId?: string
|
unitId?: string
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export const mockPipelineItems: PipelineItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pl-003',
|
id: 'pl-003',
|
||||||
|
matchId: 'match-005',
|
||||||
|
propertyId: 'prop-003',
|
||||||
propertyAddress: 'Binzstrasse 23, Zürich-Binz',
|
propertyAddress: 'Binzstrasse 23, Zürich-Binz',
|
||||||
title: 'Büro Binzstrasse 23',
|
title: 'Büro Binzstrasse 23',
|
||||||
location: 'Zürich, Binz',
|
location: 'Zürich, Binz',
|
||||||
@@ -65,6 +67,8 @@ export const mockPipelineItems: PipelineItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pl-005',
|
id: 'pl-005',
|
||||||
|
matchId: 'match-060',
|
||||||
|
propertyId: 'prop-042',
|
||||||
propertyAddress: 'Technopark, Zürich-West',
|
propertyAddress: 'Technopark, Zürich-West',
|
||||||
title: 'DataCloud Systems AG',
|
title: 'DataCloud Systems AG',
|
||||||
location: 'Zürich-West / Technopark',
|
location: 'Zürich-West / Technopark',
|
||||||
@@ -78,6 +82,8 @@ export const mockPipelineItems: PipelineItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pl-006',
|
id: 'pl-006',
|
||||||
|
matchId: 'match-061',
|
||||||
|
propertyId: 'prop-013',
|
||||||
propertyAddress: 'Wankdorf Business Park, Bern',
|
propertyAddress: 'Wankdorf Business Park, Bern',
|
||||||
title: 'Neubau Wankdorf Business',
|
title: 'Neubau Wankdorf Business',
|
||||||
location: 'Bern, Wankdorf',
|
location: 'Bern, Wankdorf',
|
||||||
@@ -91,6 +97,8 @@ export const mockPipelineItems: PipelineItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pl-007',
|
id: 'pl-007',
|
||||||
|
matchId: 'match-015',
|
||||||
|
propertyId: 'prop-015',
|
||||||
propertyAddress: 'Stadthaus-Gasse 1, Bern Innenstadt',
|
propertyAddress: 'Stadthaus-Gasse 1, Bern Innenstadt',
|
||||||
title: 'Bürofläche Stadthaus Bern',
|
title: 'Bürofläche Stadthaus Bern',
|
||||||
location: 'Bern Innenstadt',
|
location: 'Bern Innenstadt',
|
||||||
@@ -107,6 +115,8 @@ export const mockPipelineItems: PipelineItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pl-008',
|
id: 'pl-008',
|
||||||
|
matchId: 'match-002',
|
||||||
|
propertyId: 'prop-016',
|
||||||
propertyAddress: 'Güterstrasse 22, Basel',
|
propertyAddress: 'Güterstrasse 22, Basel',
|
||||||
title: 'Gewerbe Güterstrasse Basel',
|
title: 'Gewerbe Güterstrasse Basel',
|
||||||
location: 'Basel',
|
location: 'Basel',
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
MissingDataCell,
|
MissingDataCell,
|
||||||
AICompareSummary,
|
AICompareSummary,
|
||||||
} from '../../components/compare'
|
} from '../../components/compare'
|
||||||
|
import { AddToPipelineDialog } from '../../components/shortlist'
|
||||||
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||||
import type { VerifiedPortfolioResult, ExternalMarketResult, FutureAvailabilityResult } from '../../domain/unifiedResult'
|
import type { VerifiedPortfolioResult, ExternalMarketResult, FutureAvailabilityResult } from '../../domain/unifiedResult'
|
||||||
|
|
||||||
@@ -204,6 +205,8 @@ export default function Compare() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
<AddToPipelineDialog />
|
||||||
|
|
||||||
{/* Page header */}
|
{/* Page header */}
|
||||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<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 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import {
|
import {
|
||||||
Box, Typography, Chip, Paper, Button, IconButton, TextField, Divider, Tooltip,
|
Box, Typography, Chip, Paper, Button, IconButton, TextField, Divider, Tooltip, Card,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import {
|
import {
|
||||||
DndContext, DragOverlay, PointerSensor, useSensor, useSensors,
|
DndContext, DragOverlay, PointerSensor, useSensor, useSensors,
|
||||||
@@ -9,9 +9,10 @@ import {
|
|||||||
} from '@dnd-kit/core'
|
} from '@dnd-kit/core'
|
||||||
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
|
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
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 { usePipelineStore } from '../../stores/pipelineStore'
|
||||||
import { AddToPipelineDialog } from '../../components/shortlist'
|
import { AddToPipelineDialog } from '../../components/shortlist'
|
||||||
|
import { MatchScoreDisplay } from '../../components/match-card/MatchScoreDisplay'
|
||||||
import type { PipelineItem, PipelineStage } from '../../domain/pipeline'
|
import type { PipelineItem, PipelineStage } from '../../domain/pipeline'
|
||||||
|
|
||||||
// ── Stage config ──────────────────────────────────────────────────────────────
|
// ── Stage config ──────────────────────────────────────────────────────────────
|
||||||
@@ -64,6 +65,15 @@ function scoreColor(score: number) {
|
|||||||
return score >= 80 ? '#1a7a4a' : score >= 65 ? '#d97706' : '#c0392b'
|
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[] } {
|
function getKiInsight(item: PipelineItem): { summary: string; positives: string[]; risks: string[] } {
|
||||||
if (item.stage === 'SAVED') return {
|
if (item.stage === 'SAVED') return {
|
||||||
summary: `Merkliste-Eintrag mit ${item.matchScore}% Match. Prüfen Sie, ob dieses Objekt qualifiziert werden soll.`,
|
summary: `Merkliste-Eintrag mit ${item.matchScore}% Match. Prüfen Sie, ob dieses Objekt qualifiziert werden soll.`,
|
||||||
@@ -116,7 +126,10 @@ function DraggableCard({
|
|||||||
isDragOverlay?: boolean
|
isDragOverlay?: boolean
|
||||||
onChatClick?: (e: React.MouseEvent) => void
|
onChatClick?: (e: React.MouseEvent) => void
|
||||||
}) {
|
}) {
|
||||||
|
const navigate = useNavigate()
|
||||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: item.id })
|
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 ? {
|
const style = !isDragOverlay ? {
|
||||||
transform: CSS.Translate.toString(transform),
|
transform: CSS.Translate.toString(transform),
|
||||||
@@ -125,20 +138,23 @@ function DraggableCard({
|
|||||||
} : undefined
|
} : undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Card
|
||||||
ref={!isDragOverlay ? setNodeRef : undefined}
|
ref={!isDragOverlay ? setNodeRef : undefined}
|
||||||
style={style}
|
style={style}
|
||||||
elevation={isDragOverlay ? 6 : 0}
|
elevation={isDragOverlay ? 6 : 0}
|
||||||
onClick={() => !isDragging && onSelect(item)}
|
onClick={() => !isDragging && onSelect(item)}
|
||||||
sx={{
|
sx={{
|
||||||
p: 1.5,
|
p: 1.5,
|
||||||
borderRadius: 1.5,
|
border: isSelected && !isDragOverlay
|
||||||
border: isSelected && !isDragOverlay ? '2px solid #1e3a5f' : '1px solid #e2e8f0',
|
? '2px solid #1e3a5f'
|
||||||
|
: isDragOverlay
|
||||||
|
? '2px solid transparent'
|
||||||
|
: '2px solid transparent',
|
||||||
cursor: isDragOverlay ? 'grabbing' : 'grab',
|
cursor: isDragOverlay ? 'grabbing' : 'grab',
|
||||||
bgcolor: isDragOverlay ? 'white' : isSelected ? '#eff6ff' : 'white',
|
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 ? {} : {
|
'&: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',
|
borderColor: isSelected ? '#1e3a5f' : '#bfdbfe',
|
||||||
},
|
},
|
||||||
transition: isDragOverlay ? undefined : 'box-shadow 0.15s, border-color 0.15s',
|
transition: isDragOverlay ? undefined : 'box-shadow 0.15s, border-color 0.15s',
|
||||||
@@ -147,44 +163,80 @@ function DraggableCard({
|
|||||||
}}
|
}}
|
||||||
{...(isDragOverlay ? {} : { ...attributes, ...listeners })}
|
{...(isDragOverlay ? {} : { ...attributes, ...listeners })}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1, mb: 0.375 }}>
|
{/* Row 1: Score + type chip + chat icon */}
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700, flex: 1, minWidth: 0, lineHeight: 1.3 }} noWrap>
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 0.5, mb: 0.75 }}>
|
||||||
{item.title}
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flex: 1, minWidth: 0, flexWrap: 'wrap' }}>
|
||||||
</Typography>
|
<MatchScoreDisplay score={item.matchScore} size="sm" />
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, flexShrink: 0 }}>
|
<Chip
|
||||||
{item.inquiryId && onChatClick && !isDragOverlay && (
|
size="small"
|
||||||
<Tooltip title="Chat öffnen">
|
label={RESULT_TYPE_LABEL[item.resultType] ?? item.resultType}
|
||||||
<IconButton size="small" onClick={onChatClick} sx={{ p: 0.25, color: '#1e3a5f', '&:hover': { bgcolor: '#eff6ff' } }}>
|
sx={{ bgcolor: RESULT_TYPE_COLOR[item.resultType] ?? '#475569', color: 'white', fontSize: 10, height: 20, fontWeight: 600 }}
|
||||||
<MessageSquare size={13} />
|
/>
|
||||||
</IconButton>
|
<Chip
|
||||||
</Tooltip>
|
size="small"
|
||||||
)}
|
label={stageConfig.label}
|
||||||
<Typography sx={{ fontWeight: 900, fontSize: '0.9rem', color: scoreColor(item.matchScore) }}>
|
sx={{ bgcolor: stageConfig.bgColor, color: stageConfig.color, fontSize: 10, height: 20, fontWeight: 600 }}
|
||||||
{item.matchScore}%
|
/>
|
||||||
</Typography>
|
|
||||||
</Box>
|
</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>
|
</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 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
|
||||||
<MapPin size={10} color="#94a3b8" />
|
<MapPin size={11} color="#64748b" />
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.72rem' }} noWrap>
|
||||||
{item.propertyAddress ?? item.location}
|
{item.propertyAddress ?? item.location}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
|
|
||||||
<Chip
|
{/* Row 4: Area + rent */}
|
||||||
size="small"
|
{(item.areaLabel || item.rentLabel) && (
|
||||||
label={RESULT_TYPE_LABEL[item.resultType] ?? item.resultType}
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
|
||||||
sx={{ bgcolor: RESULT_TYPE_COLOR[item.resultType] ?? '#475569', color: 'white', fontSize: 10, height: 18 }}
|
{item.areaLabel && (
|
||||||
/>
|
<Typography variant="caption" sx={{ color: '#475569', fontSize: '0.72rem' }}>{item.areaLabel}</Typography>
|
||||||
{item.areaLabel && <Typography variant="caption" sx={{ color: '#475569' }}>{item.areaLabel}</Typography>}
|
)}
|
||||||
{item.rentLabel && <Typography variant="caption" sx={{ color: '#475569' }}>{item.rentLabel}</Typography>}
|
{item.areaLabel && item.rentLabel && (
|
||||||
</Box>
|
<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 && (
|
{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}
|
{item.notes}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,6 +299,7 @@ function DroppableColumn({
|
|||||||
function DetailPanel({ item, onClose }: { item: PipelineItem; onClose: () => void }) {
|
function DetailPanel({ item, onClose }: { item: PipelineItem; onClose: () => void }) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { moveStage, updateNotes, loseItem } = usePipelineStore()
|
const { moveStage, updateNotes, loseItem } = usePipelineStore()
|
||||||
|
const path = detailPath(item)
|
||||||
const [notes, setNotes] = useState(item.notes ?? '')
|
const [notes, setNotes] = useState(item.notes ?? '')
|
||||||
const stageConfig = STAGES.find(s => s.key === item.stage)!
|
const stageConfig = STAGES.find(s => s.key === item.stage)!
|
||||||
const stageIndex = STAGES.findIndex(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="body1" sx={{ fontWeight: 700, lineHeight: 1.3, mb: 0.25 }}>{item.title}</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">{item.location}</Typography>
|
<Typography variant="caption" color="text.secondary">{item.location}</Typography>
|
||||||
</Box>
|
</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) }}>
|
<Typography sx={{ fontWeight: 900, fontSize: '1.1rem', color: scoreColor(item.matchScore) }}>
|
||||||
{item.matchScore}%
|
{item.matchScore}%
|
||||||
</Typography>
|
</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' }}>
|
<IconButton size="small" onClick={onClose} sx={{ color: '#94a3b8' }}>
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const usePipelineStore = create<PipelineStore>((set, get) => ({
|
|||||||
if (!alreadyExists) {
|
if (!alreadyExists) {
|
||||||
const newItem: PipelineItem = {
|
const newItem: PipelineItem = {
|
||||||
id: pendingItem.resultId,
|
id: pendingItem.resultId,
|
||||||
|
matchId: pendingItem.resultId,
|
||||||
title: pendingItem.title,
|
title: pendingItem.title,
|
||||||
location: pendingItem.location ?? '–',
|
location: pendingItem.location ?? '–',
|
||||||
matchScore: pendingItem.matchScore,
|
matchScore: pendingItem.matchScore,
|
||||||
|
|||||||
Reference in New Issue
Block a user