feat: Decision Intelligence simplification pass

Phase 1 — MatchDetail: collapse from 11 panels to 3 visible sections
(Warum dieser Match? / Nächste Aktion / toggle). All analysis panels
(ScoreBreakdown, Risk, MissingData, NeedAlignment, Location, Tradeoffs,
FutureAvailabilityContext) hidden behind "Vollständige Analyse anzeigen".
Removes 2-column sidebar layout for cleaner single-column reading flow.

Phase 2 — Supply Dashboard: replace data quality KPIs with demand
intelligence. Hero now shows "Starke Match-Anfragen", active properties,
and Zukunftssignale count. Top 4 matches listed with score + reason +
next action. Data quality demoted to secondary collapsed notice.

Phase 3 — Future Availability: add informational banner above the results
feed when Zukunftssignale are present. Framed as professional market
intelligence (contract expiries, construction signals) not as risk.
Removed Zukunftssignal from DecisionContextPanel risks array.

Phase 4 — Naming: standardise RESULT_TYPE_META to "Verifiziertes Objekt" /
"Externes Angebot" / "Maison Work" / "Zukunftssignal" across all screens.
EXTERNAL_MARKET gets distinct amber color (#d97706). Remove Gold/Silver/
Bronze tier label from match score badge — only the % number is shown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-24 17:09:12 +02:00
parent 58a0a05eff
commit 99c26b5464
5 changed files with 265 additions and 128 deletions
+94 -78
View File
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { Box, Button, Chip, CircularProgress, Paper, Typography } from '@mui/material'
import { ArrowLeft } from 'lucide-react'
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronUp } from 'lucide-react'
import { useNavigate, useParams } from 'react-router'
import { useCompareStore } from '../../stores/compareStore'
import { usePipelineStore } from '../../stores/pipelineStore'
@@ -23,18 +24,12 @@ import { useMatchDetailData } from '../../hooks/useMatchDetailData'
import { useMatchDetail } from '../../hooks/useMatches'
import { MatchDetailHero } from '../../components/match-detail/MatchDetailHero'
import { MatchDetailPropertySections } from '../../components/match-detail/MatchDetailPropertySections'
import { RESULT_TYPE_META, DS_TEXT, DS_SURFACE, DS_BORDER, DS_BG } from '../../lib/ds'
// ── Match helpers ──────────────────────────────────────────────────────────────
// ── helpers ───────────────────────────────────────────────────────────────────
const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
const RESULT_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: 'Future Availability', color: '#7c3aed' },
}
function buildReasons(match: NonNullable<ReturnType<typeof useMatchDetail>['data']>): MatchCardReason[] {
return match.positiveFactors.slice(0, 3).map(f => ({
type: (HARD_CRITERIA.has(f.criterion) ? 'HARD_FACT' : 'SOFT_FACTOR') as MatchCardReason['type'],
@@ -44,12 +39,14 @@ function buildReasons(match: NonNullable<ReturnType<typeof useMatchDetail>['data
}))
}
// ── page ──────────────────────────────────────────────────────────────────────
export default function MatchDetail() {
const { matchId } = useParams<{ matchId: string }>()
const navigate = useNavigate()
const { addToCompare } = useCompareStore()
const { openSavedDialog } = usePipelineStore()
const [showFullAnalysis, setShowFullAnalysis] = useState(false)
const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(matchId ?? '')
@@ -79,12 +76,15 @@ export default function MatchDetail() {
const location = city
? `${city}${property?.location?.district ? `, ${property.location.district}` : ''}`
: signal?.locationHint ?? ''
// Tax calculator — only shown when AI evaluated taxEnvironment
const allFactors = [...match.positiveFactors, ...(match.negativeFactors ?? [])]
const hasTaxFactor = allFactors.some(f => f.criterion === 'taxEnvironment')
const cityIntel = city ? getCityIntelligence(city) : null
const taxCalculatorUrl = hasTaxFactor ? (cityIntel?.taxCalculatorUrl ?? undefined) : undefined
const topTradeoff = match.tradeoffs?.[0] ?? match.tradeOffs?.[0]
const summary = match.explainabilitySummary || `${match.matchStrength}-Match mit ${match.matchScore} Punkten.`
const handleCompare = () => {
if (match && !isFuture && property) {
addToCompare({
@@ -112,7 +112,6 @@ export default function MatchDetail() {
})
}
// Key facts for the strip below the hero
const keyFacts = isFuture ? [
{ label: 'Flächenschätzung', value: signal?.areaSqmEstimate ? `~${signal.areaSqmEstimate.toLocaleString('de-CH')}` : '' },
{ label: 'Zeithorizont', value: signal?.timeHorizonMonths ? `~${signal.timeHorizonMonths} Monate` : '' },
@@ -125,12 +124,12 @@ export default function MatchDetail() {
]
return (
<Box sx={{ bgcolor: '#f1f5f9', minHeight: '100vh' }}>
<Box sx={{ bgcolor: DS_BG.page, minHeight: '100vh' }}>
<AddToPipelineDialog />
{/* Sticky back nav */}
<Box sx={{
px: 3, py: 1.25, bgcolor: 'white', borderBottom: '1px solid #e2e8f0',
px: 3, py: 1.25, bgcolor: 'white', borderBottom: `1px solid ${DS_BORDER.default}`,
position: 'sticky', top: 0, zIndex: 100,
display: 'flex', alignItems: 'center', gap: 2,
}}>
@@ -138,12 +137,16 @@ export default function MatchDetail() {
startIcon={<ArrowLeft size={15} />}
onClick={() => navigate(-1)}
size="small"
sx={{ color: '#64748b', fontWeight: 500, textTransform: 'none' }}
sx={{ color: DS_TEXT.muted, fontWeight: 500, textTransform: 'none' }}
>
Zurück zu Resultaten
</Button>
{isFuture && (
<Chip label="Future Availability Signal" size="small" sx={{ bgcolor: '#faf5ff', color: '#7c3aed', fontWeight: 600 }} />
<Chip
label="Zukunftssignal"
size="small"
sx={{ bgcolor: '#faf5ff', color: '#7c3aed', fontWeight: 600 }}
/>
)}
</Box>
@@ -160,76 +163,89 @@ export default function MatchDetail() {
onShortlist={handleShortlist}
/>
{/* Main content */}
<Box sx={{ px: { xs: 1.5, sm: 3 }, py: { xs: 2, sm: 3 }, display: 'flex', flexDirection: { xs: 'column', lg: 'row' }, gap: 3, alignItems: 'flex-start' }}>
{/* ── Main content ── */}
<Box sx={{ maxWidth: 780, mx: 'auto', px: { xs: 1.5, sm: 3 }, py: { xs: 2, sm: 3 }, display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Main column */}
<Box sx={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 2 }}>
<ExecutiveSummaryPanel match={match} />
<NeedAlignmentPanel match={match} need={need} property={property} />
{/* ── Property Details ── */}
{!isFuture && property && <MatchDetailPropertySections property={property} match={match} />}
{reasons.length > 0 && (
<Paper sx={{ p: 2.5 }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Warum dieses Match</Typography>
<MatchReasonList reasons={reasons} maxItems={3} />
{match.negativeFactors.length > 0 && (
<Box sx={{ mt: 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block', mb: 0.75 }}>
Schwächere Faktoren
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{match.negativeFactors.slice(0, 3).map((f, i) => (
<Typography key={i} variant="body2" color="text.secondary">
· {f.criterion}: {f.explanation}
</Typography>
))}
</Box>
</Box>
)}
</Paper>
)}
<LocationIntelligencePanel property={property} />
{/* Map — always show when image was the hero above */}
{!isFuture && property?.images?.[0] && property?.location?.coordinates && (
<Paper sx={{ overflow: 'hidden', p: 0 }}>
<Box sx={{ px: 2.5, pt: 2, pb: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>Standort</Typography>
<Typography variant="caption" color="text.secondary">
{property.address.street} {property.address.houseNumber}, {property.address.postalCode} {property.address.city}
{/* ── Section A: Warum dieser Match? ── */}
<Paper sx={{ p: 2.5 }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.25 }}>Warum dieser Match?</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mb: reasons.length > 0 ? 1.5 : 0, lineHeight: 1.65 }}>
{summary}
</Typography>
{reasons.length > 0 && <MatchReasonList reasons={reasons} maxItems={3} />}
{topTradeoff && (
<Box sx={{
mt: 1.5, display: 'flex', gap: 0.875, alignItems: 'flex-start',
bgcolor: DS_SURFACE.warning.bg, border: `1px solid ${DS_SURFACE.warning.border}`,
borderRadius: 1.5, px: 1.5, py: 1,
}}>
<AlertTriangle size={14} color={DS_TEXT.warning} style={{ marginTop: 3, flexShrink: 0 }} />
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_TEXT.warning, display: 'block', mb: 0.25 }}>
Hauptabwägung
</Typography>
<Typography variant="body2" color="text.secondary">
{topTradeoff.concern}
</Typography>
</Box>
<PropertyMap
lat={property.location.coordinates.lat}
lng={property.location.coordinates.lng}
label={property.title}
height={220}
/>
</Paper>
</Box>
)}
</Paper>
<TradeoffPanel match={match} />
<RiskPanel match={match} />
<MissingInformationPanel match={match} />
{/* ── Section B: Nächste Aktion ── */}
<NextActionsPanel
match={match}
onCompare={handleCompare}
onShortlist={handleShortlist}
onReview={() => {}}
onReject={() => {}}
/>
{isFuture && <FutureAvailabilityContextPanel match={match} signal={signal} />}
</Box>
{/* ── Full analysis toggle ── */}
<Button
variant="outlined"
onClick={() => setShowFullAnalysis(v => !v)}
fullWidth
endIcon={showFullAnalysis ? <ChevronUp size={15} /> : <ChevronDown size={15} />}
sx={{
borderStyle: 'dashed', color: DS_TEXT.muted, borderColor: DS_BORDER.strong,
textTransform: 'none', fontWeight: 500,
'&:hover': { borderColor: DS_TEXT.muted, bgcolor: DS_BG.subtle },
}}
>
{showFullAnalysis ? 'Weniger anzeigen' : 'Vollständige Analyse anzeigen'}
</Button>
{/* Sidebar */}
<Box sx={{ width: { xs: '100%', lg: 320 }, flexShrink: 0, position: { xs: 'static', lg: 'sticky' }, top: 64, display: 'flex', flexDirection: 'column', gap: 2 }}>
<ScoreBreakdownPanel match={match} taxCalculatorUrl={taxCalculatorUrl} isFuture={isFuture} signal={signal} />
<NextActionsPanel
match={match}
onCompare={handleCompare}
onShortlist={handleShortlist}
onReview={() => {}}
onReject={() => {}}
/>
</Box>
{/* ── Full analysis (hidden by default) ── */}
{showFullAnalysis && (
<>
<ExecutiveSummaryPanel match={match} />
<NeedAlignmentPanel match={match} need={need} property={property} />
{!isFuture && property && <MatchDetailPropertySections property={property} match={match} />}
<LocationIntelligencePanel property={property} />
{!isFuture && property?.images?.[0] && property?.location?.coordinates && (
<Paper sx={{ overflow: 'hidden', p: 0 }}>
<Box sx={{ px: 2.5, pt: 2, pb: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>Standort</Typography>
<Typography variant="caption" color="text.secondary">
{property.address.street} {property.address.houseNumber}, {property.address.postalCode} {property.address.city}
</Typography>
</Box>
<PropertyMap
lat={property.location.coordinates.lat}
lng={property.location.coordinates.lng}
label={property.title}
height={220}
/>
</Paper>
)}
<TradeoffPanel match={match} />
<RiskPanel match={match} />
<MissingInformationPanel match={match} />
{isFuture && <FutureAvailabilityContextPanel match={match} signal={signal} />}
<ScoreBreakdownPanel match={match} taxCalculatorUrl={taxCalculatorUrl} isFuture={isFuture} signal={signal} />
</>
)}
</Box>
</Box>
)
+22 -3
View File
@@ -1,5 +1,6 @@
import { useState, useMemo, useEffect } from 'react'
import { Box, Button, Card, Typography } from '@mui/material'
import { Zap } from 'lucide-react'
import { useNavigate, useLocation } from 'react-router'
import { useQueryClient } from '@tanstack/react-query'
import { useUnifiedResults } from '../../hooks/useUnifiedResults'
@@ -117,11 +118,10 @@ export default function Results() {
metrics={[
...(strongCount > 0 ? [{ label: 'starke Treffer (≥80)', value: strongCount, severity: 'positive' as const }] : []),
...(maisonWorkCount > 0 ? [{ label: 'Maison Work', value: maisonWorkCount, severity: 'neutral' as const }] : []),
...(futureCount > 0 ? [{ label: 'Zukunftssignale', value: futureCount, severity: 'warning' as const }] : []),
...(futureCount > 0 ? [{ label: 'Zukunftssignale', value: futureCount, severity: 'neutral' as const }] : []),
...(missingDataCount > 0 ? [{ label: 'mit Datenlücken', value: missingDataCount, severity: 'warning' as const }] : []),
]}
risks={[
...(futureCount > 0 ? [`${futureCount} Zukunftssignal${futureCount > 1 ? 'e' : ''} sind probabilistisch — keine bestätigte Verfügbarkeit`] : []),
...(missingDataCount > 0 ? [`${missingDataCount} Treffer mit fehlenden Daten — Einschätzung eingeschränkt`] : []),
]}
actions={[
@@ -197,7 +197,26 @@ export default function Results() {
) : sorted.length === 0 ? (
<FeedEmptyState filtered={filterSource !== 'ALL' || !showFutureAvailability || !showOwnProperties} />
) : (
<UnifiedResultFeed results={sorted} view={view} />
<>
{futureCount > 0 && showFutureAvailability && (
<Box sx={{
mb: 2, px: 2, py: 1.375,
bgcolor: '#faf5ff', border: '1px solid #e9d5ff', borderLeft: '3px solid #7c3aed',
borderRadius: 1.5, display: 'flex', alignItems: 'flex-start', gap: 1.25,
}}>
<Zap size={15} color="#7c3aed" style={{ marginTop: 2, flexShrink: 0 }} />
<Box>
<Typography sx={{ fontSize: '0.8rem', fontWeight: 700, color: '#5b21b6', mb: 0.2 }}>
{futureCount} Zukunftssignal{futureCount > 1 ? 'e' : ''} in diesen Ergebnissen
</Typography>
<Typography sx={{ fontSize: '0.75rem', color: '#6d28d9', lineHeight: 1.5 }}>
Noch nicht öffentlich verfügbare Flächen gewonnen aus Mietvertrags-Auslaufzeiten, Bauprojekten und Marktbewegungen. Frühere Markttransparenz für Ihre Suche.
</Typography>
</Box>
</Box>
)}
<UnifiedResultFeed results={sorted} view={view} />
</>
)}
</Box>
</Box>