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
+29 -182
View File
@@ -5,7 +5,6 @@ import {
Button,
Card,
Chip,
LinearProgress,
Stack,
Table,
TableBody,
@@ -28,81 +27,33 @@ import {
CompareCell,
MissingDataCell,
AICompareSummary,
CompareCriteriaCard,
} from '../../components/compare'
import { AddToPipelineDialog } from '../../components/shortlist'
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
import type { VerifiedPortfolioResult, ExternalMarketResult, FutureAvailabilityResult } from '../../domain/unifiedResult'
import {
HARD_CRITERIA,
SCORE_COLOR,
TYPE_META,
RISK_LEVEL_ORDER,
CRITERION_ALIASES,
getProp,
getSig,
LABEL_SX,
DATA_SX,
scoreBar,
} from '../../components/compare/compareUtils'
// ── Helpers ───────────────────────────────────────────────────────────────────
// ── Module-level helpers ──────────────────────────────────────────────────────
const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
const SCORE_COLOR = (s: number) => s >= 78 ? '#1a7a4a' : s >= 52 ? '#d97706' : '#c0392b'
function getProp(item: UnifiedMatchResult) {
return item.resultType !== 'FUTURE_AVAILABILITY'
? (item as VerifiedPortfolioResult | ExternalMarketResult).property
: null
}
function getSig(item: UnifiedMatchResult) {
return item.resultType === 'FUTURE_AVAILABILITY'
? (item as FutureAvailabilityResult).signal
: null
}
const TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
EXTERNAL_MARKET: { label: 'Direktinserat', color: '#d97706' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
}
const RISK_LEVEL_ORDER: Record<string, number> = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
const CRITERION_ALIASES: Record<WeightingKey, string[]> = {
area: ['area', 'Fläche', 'fläche'],
location: ['location', 'Standort', 'standort'],
budget: ['budget', 'Budget', 'Mietpreis', 'mietpreis'],
timing: ['timing', 'Verfügbarkeit', 'verfügbarkeit'],
prestige: ['prestige', 'Prestige'],
accessibility: ['accessibility', 'ÖV-Anbindung', 'ÖV', 'Erreichbarkeit'],
expansionPotential:['expansionPotential', 'Expansionspotenzial'],
flexibility: ['flexibility', 'Flexibilität'],
visibility: ['visibility', 'Sichtbarkeit', 'visibilityScore'],
footfall: ['footfall', 'Passantenfrequenz', 'passerbyFrequency'],
talentAccess: ['talentAccess', 'Talent-Zugang', 'Talente'],
esg: ['esg', 'ESG', 'Nachhaltigkeit'],
taxEnvironment: ['taxEnvironment', 'Steuerlast', 'Steuerumfeld'],
}
function getTitle(item: UnifiedMatchResult): string {
const prop = getProp(item)
const sig = getSig(item)
return prop?.title ?? sig?.companyName ?? sig?.locationHint ?? item.matchId.slice(0, 8)
}
// ── Row label cell ────────────────────────────────────────────────────────────
const LABEL_SX = {
position: 'sticky' as const,
left: 0,
bgcolor: 'white',
zIndex: 1,
width: 200,
minWidth: 200,
color: '#64748b',
fontSize: 13,
fontWeight: 600,
borderRight: '1px solid #e2e8f0',
verticalAlign: 'top',
py: 1.5,
}
const DATA_SX = {
borderLeft: '1px solid #f1f5f9',
minWidth: 220,
verticalAlign: 'top',
py: 1.5,
function row(label: string, cells: ReactNode[]) {
return (
<TableRow hover key={label}>
<TableCell sx={LABEL_SX}>{label}</TableCell>
{cells.map((cell, i) => (
<TableCell key={i} sx={DATA_SX}>{cell}</TableCell>
))}
</TableRow>
)
}
// ── Main component ────────────────────────────────────────────────────────────
@@ -136,8 +87,6 @@ export default function Compare() {
.sort((a, b) => b.weight - a.weight)
: []
const maxRelevantWeight = relevantCriteria[0]?.weight ?? 1
const weightedTotals = compareItems.map(item =>
relevantCriteria.reduce((sum, { key, weight }) => {
const factor = [...item.match.positiveFactors, ...item.match.negativeFactors]
@@ -177,32 +126,6 @@ export default function Compare() {
)
const maxMissingCritical = Math.max(...missingCriticalCounts)
// ── Shared render helpers ─────────────────────────────────────────────────
function row(label: string, cells: ReactNode[]) {
return (
<TableRow hover key={label}>
<TableCell sx={LABEL_SX}>{label}</TableCell>
{cells.map((cell, i) => (
<TableCell key={i} sx={DATA_SX}>{cell}</TableCell>
))}
</TableRow>
)
}
function scoreBar(value: number, label?: string) {
const color = value >= 0.8 ? '#1a7a4a' : value >= 0.6 ? '#d97706' : '#c0392b'
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ width: 70 }}>
<LinearProgress variant="determinate" value={value * 100}
sx={{ height: 6, borderRadius: 3, '& .MuiLinearProgress-bar': { bgcolor: color } }} />
</Box>
<Typography variant="caption" sx={{ color }}>{label ?? `${Math.round(value * 100)}%`}</Typography>
</Box>
)
}
return (
<Box>
<AddToPipelineDialog />
@@ -233,88 +156,12 @@ export default function Compare() {
{/* Criteria Head-to-Head */}
{activeNeed && relevantCriteria.length > 0 && (
<Card sx={{ mb: 3, overflow: 'hidden' }}>
<Box sx={{ p: 2.5, borderBottom: '1px solid #e2e8f0', bgcolor: '#f8fafc', display: 'flex', alignItems: 'center', gap: 2 }}>
<Trophy size={18} color="#d4920e" />
<Box>
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.2 }}>Vergleich nach Suchkriterien</Typography>
<Typography variant="caption" color="text.secondary">Basierend auf der Suche: {activeNeed.companyName}</Typography>
</Box>
{overallWinnerIdx !== -1 && (
<Box sx={{ ml: 'auto', bgcolor: '#fffbeb', border: '1px solid #fbbf24', borderRadius: 2, px: 2, py: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<Trophy size={16} color="#d4920e" />
<Box>
<Typography variant="caption" sx={{ color: '#92400e', fontWeight: 700, display: 'block', lineHeight: 1 }}>Gesamtsieger</Typography>
<Typography variant="body2" sx={{ fontWeight: 800, color: '#7a4f00' }}>{getTitle(compareItems[overallWinnerIdx])}</Typography>
</Box>
</Box>
)}
</Box>
<Box sx={{ overflowX: 'auto' }}>
<Table sx={{ tableLayout: 'auto' }}>
<TableHead>
<TableRow sx={{ bgcolor: '#f8fafc' }}>
<TableCell sx={{ ...LABEL_SX, bgcolor: '#f8fafc', fontSize: 11 }}>Kriterium</TableCell>
{compareItems.map(item => (
<TableCell key={item.matchId} sx={{ ...DATA_SX, bgcolor: '#f8fafc' }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1e3a5f' }} noWrap>
{getTitle(item)}
</Typography>
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{relevantCriteria.map(({ key, label, weight }) => {
const allCriteria = (item: typeof compareItems[0]) =>
item.match.allFactors ?? [...item.match.positiveFactors, ...item.match.negativeFactors]
const factors = compareItems.map(item =>
allCriteria(item).find(f => CRITERION_ALIASES[key as WeightingKey].some(a => a.toLowerCase() === f.criterion.toLowerCase()))
)
const numericScores = factors.map(f => f?.score ?? null)
const presentScores = numericScores.filter((s): s is number => s !== null)
const maxScore = presentScores.length > 0 ? Math.max(...presentScores) : 0
const winnerIdx = compareItems.length > 1 && presentScores.length > 1 && numericScores.filter(s => s === maxScore).length === 1
? numericScores.indexOf(maxScore)
: -1
return (
<TableRow key={key} hover>
<TableCell sx={LABEL_SX}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{label}</Typography>
</TableCell>
{factors.map((factor, idx) => (
<TableCell key={idx} sx={{ ...DATA_SX, bgcolor: idx === winnerIdx ? 'rgba(26,122,74,0.05)' : undefined }}>
{factor ? (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
{scoreBar(factor.score / 100)}
{idx === winnerIdx && (
<Chip label="Besser" size="small"
icon={<CheckCircle2 size={10} />}
sx={{ height: 18, fontSize: 9, bgcolor: '#f0fdf4', color: '#166534',
'& .MuiChip-icon': { color: '#1a7a4a', ml: 0.5 },
'& .MuiChip-label': { px: 0.75 } }} />
)}
</Box>
<Typography variant="caption" color="text.secondary"
sx={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden', lineHeight: 1.4 }}>
{factor.explanation}
</Typography>
</Box>
) : (
<MissingDataCell reason="Kein Score für dieses Kriterium" />
)}
</TableCell>
))}
</TableRow>
)
})}
</TableBody>
</Table>
</Box>
</Card>
<CompareCriteriaCard
compareItems={compareItems}
relevantCriteria={relevantCriteria}
overallWinnerIdx={overallWinnerIdx}
activeNeed={activeNeed}
/>
)}
<Card sx={{ overflowX: 'auto' }}>
+5 -95
View File
@@ -1,12 +1,6 @@
import { Box, Button, Chip, CircularProgress, Divider, Paper, Typography } from '@mui/material'
import { ArrowLeft, Bookmark, Building2, Clock, Columns2, ExternalLink, Info, Layers, ShieldCheck, Tag, Train, TrendingUp } from 'lucide-react'
import { ArrowLeft, Bookmark, Building2, Clock, Columns2, ExternalLink, Info, Layers, Tag, Train, TrendingUp } from 'lucide-react'
import { useNavigate, useParams } from 'react-router'
import type { PropertyUnit } from '../../domain/property'
import { useQuery } from '@tanstack/react-query'
import { useMatchDetail } from '../../hooks/useMatches'
import { propertyService } from '../../services/propertyService'
import { needService } from '../../services/needService'
import { futureSignalService } from '../../services/futureSignalService'
import { useCompareStore } from '../../stores/compareStore'
import { usePipelineStore } from '../../stores/pipelineStore'
import { AddToPipelineDialog } from '../../components/shortlist'
@@ -26,71 +20,9 @@ import {
NextActionsPanel,
} from '../../components/match-detail'
import type { MatchCardReason } from '../../components/match-card/MatchCardViewModel'
// ── Property detail helpers ────────────────────────────────────────────────────
const FLOOR_LABEL = (level: number) =>
level === 0 ? 'EG' : level < 0 ? `UG ${Math.abs(level)}` : `${level}.OG`
const ASSET_LABELS: Record<string, string> = {
OFFICE: 'Büro', LOGISTICS: 'Lager / Logistik', RETAIL: 'Retail / Laden',
PRODUCTION: 'Produktion', MIXED: 'Gewerbe (gemischt)',
}
const RISK_LABELS: Record<string, string> = { LOW: 'Niedrig', MEDIUM: 'Mittel', HIGH: 'Hoch' }
const SOURCE_LABELS: Record<string, string> = {
ERP_IMPORT: 'ERP-Import (intern)', IMMOSCOUT_SCRAPE: 'ImmoScout24',
HOMEGATE_SCRAPE: 'Homegate', MATCHOFFICE_SCRAPE: 'MatchOffice',
NEWHOME_SCRAPE: 'newhome.ch', AI_SIGNAL: 'KI-Signal', MANUAL: 'Manuell erfasst',
}
const PASSERBY_LABELS: Record<string, string> = {
LOW: 'Gering', MEDIUM: 'Mittel', HIGH: 'Hoch', VERY_HIGH: 'Sehr hoch',
}
function KeyFactRow({ label, value }: { label: string; value?: string | null }) {
if (!value) return null
return (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', py: 0.875, borderBottom: '1px solid #f1f5f9', '&:last-of-type': { borderBottom: 0 } }}>
<Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0, mr: 2 }}>{label}</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'right' }}>{value}</Typography>
</Box>
)
}
function UnitStatusChip({ unit }: { unit: PropertyUnit }) {
if (unit.schattenmarktRelease?.enabled) {
return <Chip size="small" icon={<ShieldCheck size={11} />} label="PRE-MARKET" sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #86efac', fontWeight: 700, fontSize: '0.68rem', height: 20 }} />
}
if (unit.available) {
return <Chip size="small" label="Verfügbar" sx={{ bgcolor: '#eff6ff', color: '#1d4ed8', fontWeight: 600, fontSize: '0.68rem', height: 20 }} />
}
return <Chip size="small" label="Belegt" sx={{ bgcolor: '#f8fafc', color: '#64748b', fontWeight: 500, fontSize: '0.68rem', height: 20 }} />
}
function UnitRow({ unit, highlighted }: { unit: PropertyUnit; highlighted: boolean }) {
const availableFrom = unit.schattenmarktRelease?.availableFrom ?? unit.leaseEndDate
const monthlyRent = unit.rentPricePerSqm ? Math.round(unit.rentPricePerSqm / 12) : undefined
return (
<Box sx={{
display: 'grid', gridTemplateColumns: '1fr 80px 110px 120px auto',
gap: 1.5, alignItems: 'center', px: 2, py: 1.5, borderRadius: 1, mb: 1,
bgcolor: highlighted ? '#faf5ff' : '#f8fafc',
border: highlighted ? '1px solid #e9d5ff' : '1px solid #e2e8f0',
}}>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{FLOOR_LABEL(unit.floorLevel)}{unit.unitLabel ? ` · ${unit.unitLabel}` : ''}
</Typography>
{unit.currentTenant && <Typography variant="caption" color="text.secondary">{unit.currentTenant}</Typography>}
</Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{unit.areaSqm.toLocaleString('de-CH')} m²</Typography>
<Typography variant="body2" color="text.secondary">{monthlyRent ? `CHF ${monthlyRent}/m²/Mt.` : ''}</Typography>
<Typography variant="body2" color="text.secondary">
{availableFrom ? new Date(availableFrom).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' }) : ''}
</Typography>
<UnitStatusChip unit={unit} />
</Box>
)
}
import { useMatchDetailData } from '../../hooks/useMatchDetailData'
import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitStatusChip, UnitRow } from '../../components/match-detail/MatchDetailPropertyDetails'
import { useMatchDetail } from '../../hooks/useMatches'
// ── Match helpers ──────────────────────────────────────────────────────────────
@@ -119,29 +51,7 @@ export default function MatchDetail() {
const { addToCompare } = useCompareStore()
const { openSavedDialog } = usePipelineStore()
const { data: match, isLoading } = useMatchDetail(matchId ?? '')
const isFuture = match?.resultType === 'FUTURE_AVAILABILITY'
const { data: property = null } = useQuery({
queryKey: ['property', match?.propertyId],
queryFn: () => propertyService.getById(match!.propertyId),
enabled: !!match && !isFuture,
select: r => r.data ?? null,
})
const { data: need = null } = useQuery({
queryKey: ['need', match?.needId],
queryFn: () => needService.getById(match!.needId),
enabled: !!match?.needId,
select: r => r.data ?? null,
})
const { data: signal = null } = useQuery({
queryKey: ['signal', match?.resultId],
queryFn: () => futureSignalService.getById(match!.resultId!),
enabled: !!match && isFuture && !!match.resultId,
select: r => r.data ?? null,
})
const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(matchId ?? '')
if (isLoading) {
return (
+9 -485
View File
@@ -1,496 +1,20 @@
import { useState } from 'react'
import { useNavigate } from 'react-router'
import {
Box, Typography, Chip, Paper, Button, IconButton, TextField, Divider, Tooltip, Card,
Box, Chip, Typography,
} from '@mui/material'
import {
DndContext, DragOverlay, PointerSensor, useSensor, useSensors,
useDroppable, useDraggable, closestCenter,
DndContext, DragOverlay, PointerSensor, useSensor, useSensors, closestCenter,
} 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, ExternalLink } from 'lucide-react'
import { TrendingUp } 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 ──────────────────────────────────────────────────────────────
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
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' },
}
const RESULT_TYPE_LABEL: Record<string, string> = {
VERIFIED_PORTFOLIO: 'Portfolio',
EXTERNAL_MARKET: 'Direktinserat',
MAISON_WORK: 'Maison Work',
FUTURE_AVAILABILITY:'Future',
}
const RESULT_TYPE_COLOR: Record<string, string> = {
VERIFIED_PORTFOLIO: '#1e3a5f',
EXTERNAL_MARKET: '#d97706',
MAISON_WORK: '#0369a1',
FUTURE_AVAILABILITY: '#7c3aed',
}
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' },
],
}
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.`,
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),
}
}
// ── DraggableCard ─────────────────────────────────────────────────────────────
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" />
<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>
)
}
// ── DroppableColumn ───────────────────────────────────────────────────────────
function DroppableColumn({
stage,
items,
selectedId,
onSelect,
onChatClick,
isOver,
}: {
stage: typeof STAGES[number]
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>
)
}
// ── DetailPanel ───────────────────────────────────────────────────────────────
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>
)
}
import { STAGES } from '../../components/pipeline/pipelineConstants'
import { DraggableCard } from '../../components/pipeline/PipelineCard'
import { DroppableColumn } from '../../components/pipeline/PipelineColumn'
import { DetailPanel } from '../../components/pipeline/PipelineDetailPanel'
// ── Pipeline page ─────────────────────────────────────────────────────────────
@@ -518,8 +42,8 @@ export default function Pipeline() {
setActiveId(active.id as string)
}
function handleDragOver({ over }: { over: { id: string } | null }) {
setOverId(over?.id ?? null)
function handleDragOver({ over }: { over: { id: string | number } | null }) {
setOverId(over ? String(over.id) : null)
}
function handleDragEnd({ active, over }: DragEndEvent) {
+12 -11
View File
@@ -14,10 +14,9 @@ import {
} from '../../components/results'
import { AddToPipelineDialog } from '../../components/shortlist'
import { useSessionStore } from '../../stores/sessionStore'
import type { ResultType } from '../../domain/enums'
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
type FilterSource = Exclude<ResultType, 'FUTURE_AVAILABILITY' | 'VERIFIED_PORTFOLIO'> | 'ALL'
type FilterSource = 'ALL' | 'PLATFORM' | 'MAISON_WORK'
type SortBy = 'score' | 'rent' | 'area'
function sortResults(results: UnifiedMatchResult[], sortBy: SortBy): UnifiedMatchResult[] {
@@ -71,18 +70,21 @@ export default function Results() {
const isStaff = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
const filtered = results.filter(r => {
if (r.resultType === 'VERIFIED_PORTFOLIO') return showOwnProperties
// Future Availability toggle is independent of the source filter
if (r.resultType === 'FUTURE_AVAILABILITY') return showFutureAvailability
return filterSource === 'ALL' || r.resultType === filterSource
if (r.resultType === 'VERIFIED_PORTFOLIO') {
if (!showOwnProperties) return false
return filterSource === 'ALL' || filterSource === 'PLATFORM'
}
if (filterSource === 'ALL') return true
if (filterSource === 'PLATFORM') return r.resultType === 'EXTERNAL_MARKET'
return r.resultType === filterSource // MAISON_WORK
})
const sorted = sortResults(filtered, sortBy)
const verifiedCount = results.filter(r => r.resultType === 'VERIFIED_PORTFOLIO').length
const externalCount = results.filter(r => r.resultType === 'EXTERNAL_MARKET').length
const platformCount = results.filter(r => r.resultType === 'VERIFIED_PORTFOLIO' || r.resultType === 'EXTERNAL_MARKET').length
const maisonWorkCount = results.filter(r => r.resultType === 'MAISON_WORK').length
const futureCount = results.filter(r => r.resultType === 'FUTURE_AVAILABILITY').length
const futureCount = results.filter(r => r.resultType === 'FUTURE_AVAILABILITY').length
const strongCount = filtered.filter(r => r.matchScore >= 80).length
const missingDataCount = results.filter(r =>
'match' in r && Array.isArray((r as { match?: { missingData?: unknown[] } }).match?.missingData) &&
@@ -94,8 +96,8 @@ export default function Results() {
<AddToPipelineDialog />
<ResultFeedHeader
total={sorted.length}
verifiedCount={verifiedCount}
externalCount={externalCount}
platformCount={platformCount}
maisonWorkCount={maisonWorkCount}
futureCount={futureCount}
view={view}
onViewChange={v => { setView(v); localStorage.setItem('view-results', v) }}
@@ -107,7 +109,6 @@ export default function Results() {
context={activeNeed ? `Suche: ${activeNeed.assetType} · ${activeNeed.requiredArea.min}${activeNeed.requiredArea.max} m² · ${activeNeed.preferredLocations.join(', ')}` : undefined}
metrics={[
...(strongCount > 0 ? [{ label: 'starke Treffer (≥80)', value: strongCount, severity: 'positive' as const }] : []),
...(externalCount > 0 ? [{ label: 'Direktinserate', value: externalCount, severity: 'neutral' as const }] : []),
...(maisonWorkCount > 0 ? [{ label: 'Maison Work', value: maisonWorkCount, severity: 'neutral' as const }] : []),
...(futureCount > 0 ? [{ label: 'Zukunftssignale', value: futureCount, severity: 'warning' as const }] : []),
...(missingDataCount > 0 ? [{ label: 'mit Datenlücken', value: missingDataCount, severity: 'warning' as const }] : []),