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:
Benjamin Sutter
2026-05-23 23:36:03 +02:00
parent 72e4f08900
commit 22c195b4a5
28 changed files with 1615 additions and 1282 deletions
@@ -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>
)
}