refactor: split large page components + taxonomy/HeatBadge/FutureAvailability improvements
- Taxonomy: merge VERIFIED_PORTFOLIO + EXTERNAL_MARKET display → 'Plattform' (dark blue) across all surfaces - HeatBadge: new flame indicator for hot properties (grid, list, pipeline views) - FutureAvailabilityContextPanel: richer detail page with AI summary, strategic assessment, sources - Refactor Pipeline.tsx (630→152 lines) → pipeline/PipelineCard, PipelineColumn, PipelineDetailPanel, pipelineConstants, pipelineUtils - Refactor IntelligenceMatchCard.tsx (483→179 lines) → FutureAvailabilityCard extracted - Refactor MatchDetail.tsx (559→464 lines) → useMatchDetailData hook, MatchDetailPropertyDetails - Refactor Compare.tsx (638→485 lines) → compareUtils, CompareCriteriaCard extracted Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Box, Card, Chip, IconButton, Tooltip, Typography } from '@mui/material'
|
||||
import { useDraggable } from '@dnd-kit/core'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { ExternalLink, MapPin, MessageSquare } from 'lucide-react'
|
||||
import { MatchScoreDisplay } from '../match-card/MatchScoreDisplay'
|
||||
import { HeatBadge } from '../shared'
|
||||
import type { PipelineItem } from '../../domain/pipeline'
|
||||
import { STAGES, RESULT_TYPE_LABEL, RESULT_TYPE_COLOR } from './pipelineConstants'
|
||||
import { detailPath } from './pipelineUtils'
|
||||
|
||||
// ── DraggableCard ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function DraggableCard({
|
||||
item,
|
||||
isSelected,
|
||||
onSelect,
|
||||
isDragOverlay = false,
|
||||
onChatClick,
|
||||
}: {
|
||||
item: PipelineItem
|
||||
isSelected: boolean
|
||||
onSelect: (item: PipelineItem) => void
|
||||
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),
|
||||
opacity: isDragging ? 0.35 : 1,
|
||||
transition: isDragging ? undefined : 'opacity 0.15s ease',
|
||||
} : undefined
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={!isDragOverlay ? setNodeRef : undefined}
|
||||
style={style}
|
||||
elevation={isDragOverlay ? 6 : 0}
|
||||
onClick={() => !isDragging && onSelect(item)}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
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)' : '0 1px 3px rgba(0,0,0,0.08)',
|
||||
'&:hover': isDragOverlay ? {} : {
|
||||
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',
|
||||
userSelect: 'none',
|
||||
rotate: isDragOverlay ? '2deg' : undefined,
|
||||
}}
|
||||
{...(isDragOverlay ? {} : { ...attributes, ...listeners })}
|
||||
>
|
||||
{/* 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" />
|
||||
<HeatBadge propertyId={item.propertyId} 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={11} color="#64748b" />
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.72rem' }} noWrap>
|
||||
{item.propertyAddress ?? item.location}
|
||||
</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', fontSize: '0.7rem' }} noWrap>
|
||||
{item.notes}
|
||||
</Typography>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { useDroppable } from '@dnd-kit/core'
|
||||
import type { PipelineItem, PipelineStage } from '../../domain/pipeline'
|
||||
import { DraggableCard } from './PipelineCard'
|
||||
|
||||
// ── DroppableColumn ───────────────────────────────────────────────────────────
|
||||
|
||||
export function DroppableColumn({
|
||||
stage,
|
||||
items,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onChatClick,
|
||||
isOver,
|
||||
}: {
|
||||
stage: { key: PipelineStage; label: string; color: string; bgColor: string }
|
||||
items: PipelineItem[]
|
||||
selectedId: string | null
|
||||
onSelect: (item: PipelineItem) => void
|
||||
onChatClick: (inquiryId: string) => void
|
||||
isOver: boolean
|
||||
}) {
|
||||
const { setNodeRef } = useDroppable({ id: stage.key })
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={setNodeRef}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
minHeight: 80,
|
||||
p: 0.75,
|
||||
borderRadius: 2,
|
||||
bgcolor: isOver ? `${stage.color}10` : 'transparent',
|
||||
border: isOver ? `2px dashed ${stage.color}60` : '2px solid transparent',
|
||||
transition: 'background-color 0.15s, border-color 0.15s',
|
||||
}}
|
||||
>
|
||||
{items.map(item => (
|
||||
<DraggableCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
isSelected={item.id === selectedId}
|
||||
onSelect={onSelect}
|
||||
onChatClick={item.inquiryId ? (e) => { e.stopPropagation(); onChatClick(item.inquiryId!) } : undefined}
|
||||
/>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<Box sx={{ py: 2, textAlign: 'center' }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
||||
{isOver ? 'Hier ablegen' : 'Leer'}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import {
|
||||
Box, Button, Chip, Divider, IconButton, TextField, Tooltip, Typography,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
AlertTriangle, ChevronRight, CheckCircle, ExternalLink, FileText,
|
||||
MapPin, MessageSquare, Sparkles, StickyNote, X,
|
||||
} from 'lucide-react'
|
||||
import { usePipelineStore } from '../../stores/pipelineStore'
|
||||
import type { PipelineItem } from '../../domain/pipeline'
|
||||
import { STAGES, NEXT_STAGE, MOCK_DOCS } from './pipelineConstants'
|
||||
import { scoreColor, detailPath, getKiInsight } from './pipelineUtils'
|
||||
|
||||
// ── DetailPanel ───────────────────────────────────────────────────────────────
|
||||
|
||||
export 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)
|
||||
const nextStage = NEXT_STAGE[item.stage]
|
||||
const ki = getKiInsight(item)
|
||||
const docs = MOCK_DOCS[item.id] ?? []
|
||||
const isClosed = item.stage === 'CLOSED_WON' || item.stage === 'CLOSED_LOST'
|
||||
const progressIdx = Math.min(stageIndex, 4)
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
width: { xs: '100%', md: 340 }, flexShrink: 0,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
bgcolor: 'white', borderLeft: '1px solid #e2e8f0', overflow: 'hidden',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<Box sx={{ px: 2, py: 2, borderBottom: '1px solid #e2e8f0' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<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.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>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Box sx={{ display: 'flex', gap: 0.25, mb: 1 }}>
|
||||
{STAGES.slice(0, 5).map((s, idx) => (
|
||||
<Box key={s.key} sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: idx <= progressIdx ? stageConfig.color : '#e2e8f0' }} />
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip label={stageConfig.label} size="small" sx={{ bgcolor: stageConfig.bgColor, color: stageConfig.color, fontWeight: 700, height: 22, fontSize: '0.75rem' }} />
|
||||
{item.inquiryId && (
|
||||
<Chip
|
||||
icon={<MessageSquare size={11} />}
|
||||
label="Chat"
|
||||
size="small"
|
||||
onClick={() => navigate(`/demand/anfragen?inquiry=${item.inquiryId}`)}
|
||||
sx={{
|
||||
height: 22, fontSize: '0.75rem', cursor: 'pointer',
|
||||
bgcolor: '#eff6ff', color: '#1e3a5f', fontWeight: 600,
|
||||
border: '1px solid #bfdbfe',
|
||||
'& .MuiChip-icon': { color: '#1e3a5f' },
|
||||
'&:hover': { bgcolor: '#dbeafe' },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Property / unit address */}
|
||||
{item.propertyAddress && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 1.25 }}>
|
||||
<MapPin size={12} color="#64748b" />
|
||||
<Typography variant="caption" color="text.secondary">{item.propertyAddress}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||||
{/* KI */}
|
||||
<Box sx={{ px: 2, pt: 2, pb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1 }}>
|
||||
<Sparkles size={13} color="#7c3aed" />
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#7c3aed', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.7rem' }}>
|
||||
KI Einschätzung
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ bgcolor: '#faf5ff', border: '1px solid #ddd6fe', borderRadius: 2, p: 1.5, mb: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: '#4c1d95', lineHeight: 1.6 }}>{ki.summary}</Typography>
|
||||
</Box>
|
||||
{ki.positives.map((p, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75, mb: 0.375 }}>
|
||||
<CheckCircle size={12} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ color: '#166534', lineHeight: 1.4 }}>{p}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
{ki.risks.map((r, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75, mb: 0.375 }}>
|
||||
<AlertTriangle size={12} color="#d97706" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ color: '#92400e', lineHeight: 1.4 }}>{r}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Stage actions */}
|
||||
{!isClosed && (
|
||||
<Box sx={{ px: 2, py: 1.5 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#475569', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.7rem', display: 'block', mb: 1 }}>
|
||||
Nächste Aktion
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{nextStage && (
|
||||
<Button size="small" variant="contained" endIcon={<ChevronRight size={14} />}
|
||||
onClick={() => moveStage(item.id, nextStage.key)}
|
||||
sx={{ bgcolor: stageConfig.color, '&:hover': { filter: 'brightness(0.9)' }, fontSize: '0.75rem', py: 0.5 }}
|
||||
>
|
||||
{nextStage.label}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="small" variant="outlined" onClick={() => loseItem(item.id)}
|
||||
sx={{ color: '#c0392b', borderColor: '#c0392b', fontSize: '0.75rem', py: 0.5 }}>
|
||||
Ablehnen
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Notes */}
|
||||
<Box sx={{ px: 2, py: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1 }}>
|
||||
<StickyNote size={13} color="#475569" />
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#475569', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.7rem' }}>
|
||||
Notizen
|
||||
</Typography>
|
||||
</Box>
|
||||
<TextField
|
||||
size="small" multiline minRows={3} fullWidth
|
||||
placeholder="Notiz hinzufügen…"
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
onBlur={() => updateNotes(item.id, notes)}
|
||||
sx={{ '& .MuiOutlinedInput-root': { fontSize: '0.8rem', borderRadius: 1.5 } }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Documents */}
|
||||
<Box sx={{ px: 2, py: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1 }}>
|
||||
<FileText size={13} color="#475569" />
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#475569', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.7rem' }}>
|
||||
Dokumente
|
||||
</Typography>
|
||||
</Box>
|
||||
{docs.length === 0 ? (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>Noch keine Dokumente.</Typography>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{docs.map((doc, i) => (
|
||||
<Box key={i} sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1,
|
||||
px: 1.5, py: 0.75, bgcolor: '#f8fafc',
|
||||
borderRadius: 1.5, border: '1px solid #e2e8f0',
|
||||
cursor: 'pointer', '&:hover': { bgcolor: '#f1f5f9' },
|
||||
}}>
|
||||
<FileText size={13} color="#475569" />
|
||||
<Typography variant="caption" sx={{ flex: 1, color: '#1e293b' }} noWrap>{doc.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>{doc.date}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<Button size="small" variant="text" sx={{ mt: 0.75, color: '#1e3a5f', fontSize: '0.75rem', p: 0 }}>
|
||||
+ Dokument hochladen
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: 2, pb: 2 }}>
|
||||
<Divider sx={{ mb: 1.5 }} />
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{item.availabilityLabel && <Typography variant="caption" color="text.secondary"><strong>Verfügbar:</strong> {item.availabilityLabel}</Typography>}
|
||||
{item.assignedTo && <Typography variant="caption" color="text.secondary"><strong>Verantwortlich:</strong> {item.assignedTo}</Typography>}
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<strong>Hinzugefügt:</strong> {new Date(item.addedAt).toLocaleDateString('de-CH')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { PipelineStage } from '../../domain/pipeline'
|
||||
|
||||
// ── Stage config ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const STAGES = [
|
||||
{ key: 'SAVED' as PipelineStage, label: 'Gemerkt', color: '#475569', bgColor: '#f8fafc' },
|
||||
{ key: 'DISCOVERED' as PipelineStage, label: 'Entdeckt', color: '#0369a1', bgColor: '#f0f9ff' },
|
||||
{ key: 'QUALIFIED' as PipelineStage, label: 'Qualifiziert', color: '#1e3a5f', bgColor: '#eff6ff' },
|
||||
{ key: 'VISITED' as PipelineStage, label: 'Besichtigt', color: '#d97706', bgColor: '#fffbeb' },
|
||||
{ key: 'NEGOTIATION' as PipelineStage, label: 'Verhandlung', color: '#7c3aed', bgColor: '#faf5ff' },
|
||||
{ key: 'CLOSED_WON' as PipelineStage, label: 'Gewonnen', color: '#1a7a4a', bgColor: '#f0fdf4' },
|
||||
{ key: 'CLOSED_LOST' as PipelineStage, label: 'Abgelehnt', color: '#c0392b', bgColor: '#fef2f2' },
|
||||
] as const
|
||||
|
||||
export const NEXT_STAGE: Partial<Record<PipelineStage, { key: PipelineStage; label: string }>> = {
|
||||
SAVED: { key: 'DISCOVERED', label: 'Als entdeckt markieren' },
|
||||
DISCOVERED: { key: 'QUALIFIED', label: 'Qualifizieren' },
|
||||
QUALIFIED: { key: 'VISITED', label: 'Besichtigung planen' },
|
||||
VISITED: { key: 'NEGOTIATION', label: 'Verhandlung starten' },
|
||||
NEGOTIATION: { key: 'CLOSED_WON', label: 'Als gewonnen markieren' },
|
||||
}
|
||||
|
||||
export const RESULT_TYPE_LABEL: Record<string, string> = {
|
||||
VERIFIED_PORTFOLIO: 'Plattform',
|
||||
EXTERNAL_MARKET: 'Plattform',
|
||||
MAISON_WORK: 'Maison Work',
|
||||
FUTURE_AVAILABILITY: 'Future',
|
||||
}
|
||||
|
||||
export const RESULT_TYPE_COLOR: Record<string, string> = {
|
||||
VERIFIED_PORTFOLIO: '#1e3a5f',
|
||||
EXTERNAL_MARKET: '#1e3a5f',
|
||||
MAISON_WORK: '#0369a1',
|
||||
FUTURE_AVAILABILITY: '#7c3aed',
|
||||
}
|
||||
|
||||
export const MOCK_DOCS: Record<string, { name: string; date: string }[]> = {
|
||||
'pl-001': [
|
||||
{ name: 'Expose_Zollstrasse12.pdf', date: '05.05.2026' },
|
||||
{ name: 'Grundriss_EG.pdf', date: '08.05.2026' },
|
||||
{ name: 'Mietvertrag_Entwurf.docx', date: '14.05.2026' },
|
||||
],
|
||||
'pl-007': [
|
||||
{ name: 'Expose_Stadthaus_Bern.pdf', date: '12.04.2026' },
|
||||
{ name: 'Mietvertrag_unterschrieben.pdf', date: '02.05.2026' },
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { PipelineItem } from '../../domain/pipeline'
|
||||
|
||||
export function scoreColor(score: number) {
|
||||
return score >= 80 ? '#1a7a4a' : score >= 65 ? '#d97706' : '#c0392b'
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export 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.`,
|
||||
positives: [`Match ${item.matchScore}%`],
|
||||
risks: ['Noch nicht qualifiziert'],
|
||||
}
|
||||
if (item.stage === 'CLOSED_WON') return {
|
||||
summary: `Abschluss erfolgreich. ${item.title} wurde zu ${item.matchScore}% Match abgeschlossen.`,
|
||||
positives: ['Vertraglich gesichert', `Match ${item.matchScore}%`, 'Alle Kriterien erfüllt'],
|
||||
risks: [],
|
||||
}
|
||||
if (item.stage === 'CLOSED_LOST') return {
|
||||
summary: item.notes ?? 'Objekt nicht realisiert.',
|
||||
positives: [],
|
||||
risks: ['Nicht verfügbar', 'Alternative Optionen prüfen'],
|
||||
}
|
||||
const s = item.matchScore
|
||||
return {
|
||||
summary: s >= 80
|
||||
? `Starkes Objekt (${s}%) — deckt die wesentlichen Suchkriterien ab. Prozess aktiv weitertreiben.`
|
||||
: s >= 65
|
||||
? `Solides Objekt (${s}%) mit Potenzial. Gezielte Klärung offener Punkte empfohlen.`
|
||||
: `Schwächerer Match (${s}%). Abweichungen kritisch prüfen bevor weitere Ressourcen investiert werden.`,
|
||||
positives: [
|
||||
...(s >= 80 ? [`Match ${s}% — hohe Übereinstimmung`] : []),
|
||||
...(item.areaLabel ? [`Fläche: ${item.areaLabel}`] : []),
|
||||
...(item.resultType === 'VERIFIED_PORTFOLIO' ? ['Geprüftes Portfolio-Objekt'] : []),
|
||||
...(item.stage === 'NEGOTIATION' ? ['Verhandlung läuft — kurz vor Abschluss'] : []),
|
||||
].slice(0, 3),
|
||||
risks: [
|
||||
...(s < 80 ? [`Match ${s}% — Abweichungen prüfen`] : []),
|
||||
...(item.notes?.includes('Budget') ? ['Budget-Diskrepanz erwähnt'] : []),
|
||||
...(item.resultType === 'FUTURE_AVAILABILITY' ? ['Verfügbarkeit noch nicht bestätigt'] : []),
|
||||
].slice(0, 2),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user