diff --git a/src/App.tsx b/src/App.tsx
index 0b127a1..4dada50 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -15,6 +15,7 @@ const DataQuality = lazy(() => import('./pages/supply/DataQuality'))
const AISearch = lazy(() => import('./pages/demand/AISearch'))
const Results = lazy(() => import('./pages/demand/Results'))
+const MatchDetail = lazy(() => import('./pages/demand/MatchDetail'))
const Compare = lazy(() => import('./pages/demand/Compare'))
const Shortlists = lazy(() => import('./pages/demand/Shortlists'))
@@ -52,6 +53,7 @@ function App() {
}>
} />
} />
+ } />
} />
} />
diff --git a/src/components/match-detail/ExecutiveSummaryPanel.tsx b/src/components/match-detail/ExecutiveSummaryPanel.tsx
new file mode 100644
index 0000000..0be9810
--- /dev/null
+++ b/src/components/match-detail/ExecutiveSummaryPanel.tsx
@@ -0,0 +1,57 @@
+import { Box, Paper, Typography } from '@mui/material'
+import { CheckCircle2, AlertTriangle, ArrowRight, Target } from 'lucide-react'
+import type { Match } from '../../domain/match'
+
+interface Props {
+ match: Match
+}
+
+export function ExecutiveSummaryPanel({ match }: Props) {
+ const topReason = match.positiveFactors[0]
+ const topTradeoff = match.tradeoffs?.[0]
+ const nextStep = match.nextBestActions?.[0]
+
+ const rows: { icon: React.ReactNode; label: string; text: string }[] = [
+ {
+ icon: ,
+ label: 'Gesamteignung',
+ text: match.explainabilitySummary || `${match.matchStrength}-Match mit ${match.matchScore} Punkten`,
+ },
+ ...(topReason ? [{
+ icon: ,
+ label: 'Stärkster Grund',
+ text: topReason.explanation,
+ }] : []),
+ ...(topTradeoff ? [{
+ icon: ,
+ label: 'Hauptabwägung',
+ text: topTradeoff.concern,
+ }] : []),
+ ...(nextStep ? [{
+ icon: ,
+ label: 'Empfohlener nächster Schritt',
+ text: nextStep.description ?? nextStep.label,
+ }] : []),
+ ]
+
+ return (
+
+ Executive Summary
+
+ {rows.map((row, i) => (
+
+ {row.icon}
+
+
+ {row.label}
+
+
+ {row.text}
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/match-detail/FutureAvailabilityContextPanel.tsx b/src/components/match-detail/FutureAvailabilityContextPanel.tsx
new file mode 100644
index 0000000..665bec9
--- /dev/null
+++ b/src/components/match-detail/FutureAvailabilityContextPanel.tsx
@@ -0,0 +1,97 @@
+import { Alert, Box, Chip, Paper, Typography } from '@mui/material'
+import type { Match } from '../../domain/match'
+import type { FutureSignal } from '../../domain/futureSignal'
+
+const SIGNAL_TYPE_LABELS: Record = {
+ EXPANSION: 'Expansion',
+ POSSIBLE_MOVE_OUT: 'Möglicher Auszug',
+ CONSTRUCTION_PROJECT: 'Bauvorhaben',
+ RESTRUCTURING: 'Restrukturierung',
+ PROJECT_DEVELOPMENT: 'Projektentwicklung',
+ SPACE_CONSOLIDATION: 'Flächenkonsolidierung',
+}
+
+const SENSITIVITY_META: Record = {
+ CONFIDENTIAL: { label: 'Vertraulich', color: 'error' },
+ INTERNAL: { label: 'Intern', color: 'warning' },
+ PUBLIC: { label: 'Öffentlich', color: 'default' },
+}
+
+const REVIEW_STATUS_META: Record = {
+ UNREVIEWED: { label: 'Ungeprüft', color: 'warning' },
+ IN_REVIEW: { label: 'In Prüfung', color: 'warning' },
+ APPROVED: { label: 'Genehmigt', color: 'success' },
+ REJECTED: { label: 'Abgelehnt', color: 'error' },
+ FLAGGED: { label: 'Markiert', color: 'warning' },
+}
+
+interface Props {
+ match: Match
+ signal: FutureSignal | null
+}
+
+export function FutureAvailabilityContextPanel({ match: _match, signal }: Props) {
+ if (!signal) return null
+
+ const sensitiveMeta = SENSITIVITY_META[signal.sensitivityLevel] ?? SENSITIVITY_META.PUBLIC
+ const reviewMeta = signal.reviewStatus ? (REVIEW_STATUS_META[signal.reviewStatus] ?? null) : null
+
+ return (
+
+ Zukunftssignal – Kontext
+
+ {/* Mandatory disclaimer */}
+
+ {signal.disclaimer}
+
+
+
+ {signal.signalType && (
+
+ Signaltyp
+ {SIGNAL_TYPE_LABELS[signal.signalType] ?? signal.signalType}
+
+ )}
+
+ Konfidenz
+
+ {Math.round(signal.confidenceScore * 100)}%
+
+
+
+ Wahrscheinlichkeit
+
+ {Math.round(signal.probability * 100)}%
+
+
+
+ Zeithorizont
+ ~{signal.timeHorizonMonths} Monate
+
+
+ Vertraulichkeit
+
+
+ {reviewMeta && (
+
+ Prüfstatus
+
+
+ )}
+
+ Quellglaubwürdigkeit
+ {signal.source.credibility}
+
+
+
+ {signal.evidence?.summary && (
+
+
+ Evidenz
+
+ {signal.evidence.summary}
+
+ )}
+
+ )
+}
diff --git a/src/components/match-detail/MatchDetailHeader.tsx b/src/components/match-detail/MatchDetailHeader.tsx
new file mode 100644
index 0000000..53b9e21
--- /dev/null
+++ b/src/components/match-detail/MatchDetailHeader.tsx
@@ -0,0 +1,113 @@
+import { Alert, Box, Button, Chip, Paper, Typography } from '@mui/material'
+import { ArrowLeft, Bookmark, Columns2 } from 'lucide-react'
+import { MatchScoreDisplay } from '../match-card/MatchScoreDisplay'
+import type { Match } from '../../domain/match'
+import type { Property } from '../../domain/property'
+import type { FutureSignal } from '../../domain/futureSignal'
+
+const RESULT_TYPE_META: Record = {
+ VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
+ EXTERNAL_MARKET: { label: 'Marktinserat', color: '#d97706' },
+ FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
+}
+
+function confColor(c: number): string {
+ return c >= 0.75 ? '#1a7a4a' : c >= 0.55 ? '#d97706' : '#c0392b'
+}
+
+function dqColor(q: number): string {
+ return q >= 0.80 ? '#1a7a4a' : q >= 0.60 ? '#d97706' : '#c0392b'
+}
+
+interface Props {
+ match: Match
+ property: Property | null
+ signal: FutureSignal | null
+ onBack: () => void
+ onCompare: () => void
+ onShortlist: () => void
+}
+
+export function MatchDetailHeader({ match, property, signal, onBack, onCompare, onShortlist }: Props) {
+ const rt = RESULT_TYPE_META[match.resultType ?? 'VERIFIED_PORTFOLIO'] ?? { label: '–', color: '#64748b' }
+ const title = property?.title ?? signal?.companyName ?? signal?.locationHint ?? '–'
+ const isFuture = match.resultType === 'FUTURE_AVAILABILITY'
+ const dqScore = property?.dataQuality?.score ?? signal?.confidenceScore ?? 0.5
+ const confPct = Math.round(match.confidenceLevel * 100)
+ const dqPct = Math.round(dqScore * 100)
+ const location = property?.location?.city
+ ? `${property.location.city}${property.location.district ? `, ${property.location.district}` : ''}`
+ : signal?.locationHint ?? '–'
+ const source = property?.sourceLabel ?? property?.sourceMeta?.sourceLabel ?? '–'
+ const availability = property?.availabilityDate ?? (signal?.timeHorizonMonths ? `~${signal.timeHorizonMonths} Monate` : undefined)
+
+ return (
+
+ {isFuture && (
+
+ Probabilistisches Signal – keine bestätigte Fläche. Alle Angaben sind Schätzungen.
+
+ )}
+
+ }
+ onClick={onBack}
+ size="small"
+ sx={{ mb: 1.5, color: '#64748b' }}
+ >
+ Zurück zu Resultaten
+
+
+
+
+ {title}
+ {location}
+
+
+
+ {property?.assetType && (
+
+ )}
+
+
+ {availability && }
+ {source !== '–' && (
+ Quelle: {source}
+ )}
+
+
+
+
+
+
+ }
+ onClick={onShortlist}
+ >
+ Shortlist
+
+ }
+ onClick={onCompare}
+ sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
+ >
+ Vergleichen
+
+
+
+
+
+ )
+}
diff --git a/src/components/match-detail/MissingInformationPanel.tsx b/src/components/match-detail/MissingInformationPanel.tsx
new file mode 100644
index 0000000..e8f2a3d
--- /dev/null
+++ b/src/components/match-detail/MissingInformationPanel.tsx
@@ -0,0 +1,75 @@
+import { Box, Button, Chip, Paper, Typography } from '@mui/material'
+import { FileQuestion } from 'lucide-react'
+import type { Match, MissingDataItem } from '../../domain/match'
+
+const IMPORTANCE_ORDER = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']
+const IMPORTANCE_META: Record = {
+ CRITICAL: { label: 'Kritisch', color: 'error' },
+ HIGH: { label: 'Wichtig', color: 'warning' },
+ MEDIUM: { label: 'Optional', color: 'default' },
+ LOW: { label: 'Optional', color: 'default' },
+}
+
+interface MissingItemRowProps {
+ item: MissingDataItem
+}
+
+function MissingItemRow({ item }: MissingItemRowProps) {
+ const meta = IMPORTANCE_META[item.importance] ?? IMPORTANCE_META.LOW
+
+ return (
+
+
+
+ {item.field}
+
+
+
+ {item.description}
+
+
+ Auswirkung: {item.impact}
+
+
+
+
+
+
+
+ )
+}
+
+interface Props {
+ match: Match
+}
+
+export function MissingInformationPanel({ match }: Props) {
+ const missingData: MissingDataItem[] = match.missingData ?? []
+ if (missingData.length === 0) return null
+
+ const sorted = [...missingData].sort(
+ (a, b) => IMPORTANCE_ORDER.indexOf(a.importance) - IMPORTANCE_ORDER.indexOf(b.importance)
+ )
+
+ return (
+
+
+ Fehlende Informationen
+ m.importance === 'CRITICAL') ? 'error' : 'warning'}
+ />
+
+ {sorted.map((item, i) => (
+
+ ))}
+
+ )
+}
diff --git a/src/components/match-detail/NeedAlignmentPanel.tsx b/src/components/match-detail/NeedAlignmentPanel.tsx
new file mode 100644
index 0000000..fd9479f
--- /dev/null
+++ b/src/components/match-detail/NeedAlignmentPanel.tsx
@@ -0,0 +1,153 @@
+import { Box, Chip, Paper, Typography } from '@mui/material'
+import { CheckCircle2, XCircle, Minus } from 'lucide-react'
+import type { Match } from '../../domain/match'
+import type { Property } from '../../domain/property'
+import type { Need } from '../../domain/need'
+
+type FitStatus = 'MATCH' | 'NO_MATCH' | 'PARTIAL' | 'UNKNOWN'
+
+interface AlignmentRow {
+ label: string
+ needValue: string
+ resultValue: string
+ fit: FitStatus
+}
+
+function fitIcon(fit: FitStatus) {
+ if (fit === 'MATCH') return
+ if (fit === 'NO_MATCH') return
+ if (fit === 'PARTIAL') return
+ return
+}
+
+function fitColor(fit: FitStatus): string {
+ if (fit === 'MATCH') return '#f0fdf4'
+ if (fit === 'NO_MATCH') return '#fef2f2'
+ if (fit === 'PARTIAL') return '#fffbeb'
+ return '#f8fafc'
+}
+
+function buildRows(need: Need, property: Property): AlignmentRow[] {
+ const rows: AlignmentRow[] = []
+
+ // Area
+ const areaSqm = property.areaSqm
+ const areaFit: FitStatus = areaSqm >= need.requiredArea.min && areaSqm <= need.requiredArea.max
+ ? 'MATCH'
+ : areaSqm >= need.requiredArea.min * 0.85
+ ? 'PARTIAL'
+ : 'NO_MATCH'
+ rows.push({
+ label: 'Fläche',
+ needValue: `${need.requiredArea.min}–${need.requiredArea.max} m²`,
+ resultValue: `${areaSqm} m²`,
+ fit: areaFit,
+ })
+
+ // Location
+ const locationFit: FitStatus = need.preferredLocations.some(
+ l => l.toLowerCase() === property.location.city.toLowerCase() ||
+ l.toLowerCase() === property.location.district?.toLowerCase()
+ ) ? 'MATCH' : 'PARTIAL'
+ rows.push({
+ label: 'Standort',
+ needValue: need.preferredLocations.join(', ') || '–',
+ resultValue: property.location.city,
+ fit: locationFit,
+ })
+
+ // Budget
+ const budgetFit: FitStatus = property.rentPricePerSqm <= need.budgetRange.maxPerSqm
+ ? 'MATCH'
+ : property.rentPricePerSqm <= need.budgetRange.maxPerSqm * 1.1
+ ? 'PARTIAL'
+ : 'NO_MATCH'
+ rows.push({
+ label: 'Budget',
+ needValue: `max. CHF ${need.budgetRange.maxPerSqm}/m²`,
+ resultValue: `CHF ${property.rentPricePerSqm}/m²`,
+ fit: budgetFit,
+ })
+
+ // Timing
+ const availDate = property.availabilityDate
+ const latestMoveIn = need.timing.latestMoveIn
+ const timingFit: FitStatus = !availDate ? 'UNKNOWN'
+ : availDate <= latestMoveIn ? 'MATCH' : 'PARTIAL'
+ rows.push({
+ label: 'Verfügbarkeit',
+ needValue: `bis ${need.timing.latestMoveIn}`,
+ resultValue: availDate || 'unbekannt',
+ fit: timingFit,
+ })
+
+ return rows
+}
+
+interface Props {
+ match: Match
+ need: Need | null
+ property: Property | null
+}
+
+export function NeedAlignmentPanel({ match: _match, need, property }: Props) {
+ if (!need || !property) return null
+
+ const rows = buildRows(need, property)
+ const mustHaves = need.mustHaveCriteria ?? []
+
+ return (
+
+ Need-Alignment
+
+ Gesuch: {need.companyName} · {need.assetType}
+
+
+ {/* Comparison table */}
+
+
+ Kriterium
+ Gesuch
+ Objekt
+ Fit
+
+ {rows.map((row, i) => (
+
+ {row.label}
+ {row.needValue}
+ {row.resultValue}
+
+ {fitIcon(row.fit)}
+
+
+ ))}
+
+
+ {/* Must-haves */}
+ {mustHaves.length > 0 && (
+
+
+ Must-have Kriterien
+
+
+ {mustHaves.map((m, i) => (
+
+ ))}
+
+
+ )}
+
+ )
+}
diff --git a/src/components/match-detail/NextActionsPanel.tsx b/src/components/match-detail/NextActionsPanel.tsx
new file mode 100644
index 0000000..ecdf6d5
--- /dev/null
+++ b/src/components/match-detail/NextActionsPanel.tsx
@@ -0,0 +1,80 @@
+import { Box, Button, Paper, Typography } from '@mui/material'
+import type { Match, NextBestAction } from '../../domain/match'
+
+const PRIORITY_ORDER: Record = { HIGH: 0, MEDIUM: 1, LOW: 2 }
+
+const PRIORITY_COLOR: Record = {
+ HIGH: 'contained',
+ MEDIUM: 'outlined',
+ LOW: 'outlined',
+}
+
+interface Props {
+ match: Match
+ onCompare?: () => void
+ onShortlist?: () => void
+ onReject?: () => void
+ onReview?: () => void
+}
+
+export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onReview }: Props) {
+ const engineActions: NextBestAction[] = [...(match.nextBestActions ?? [])].sort(
+ (a, b) => (PRIORITY_ORDER[a.priority] ?? 2) - (PRIORITY_ORDER[b.priority] ?? 2)
+ )
+
+ return (
+
+ Empfohlene Aktionen
+
+ {engineActions.length > 0 && (
+
+ {engineActions.map((action, i) => (
+
+
+ {action.description && (
+
+ {action.description}
+
+ )}
+
+ ))}
+
+ )}
+
+ {/* Standard actions */}
+
+ {onShortlist && (
+
+ )}
+ {onCompare && (
+
+ )}
+ {onReview && (
+
+ )}
+ {onReject && (
+
+ )}
+
+
+ )
+}
diff --git a/src/components/match-detail/PropertyOverviewPanel.tsx b/src/components/match-detail/PropertyOverviewPanel.tsx
new file mode 100644
index 0000000..84aad96
--- /dev/null
+++ b/src/components/match-detail/PropertyOverviewPanel.tsx
@@ -0,0 +1,67 @@
+import { Box, Chip, Paper, Typography } from '@mui/material'
+import { Banknote, Calendar, MapPin, Maximize2, Tag } from 'lucide-react'
+import type { Match } from '../../domain/match'
+import type { Property } from '../../domain/property'
+import type { FutureSignal } from '../../domain/futureSignal'
+
+interface FactRowProps {
+ icon: React.ReactNode
+ label: string
+ value: string
+}
+
+function FactRow({ icon, label, value }: FactRowProps) {
+ return (
+
+ {icon}
+ {label}
+ {value}
+
+ )
+}
+
+interface Props {
+ match: Match
+ property: Property | null
+ signal: FutureSignal | null
+}
+
+export function PropertyOverviewPanel({ match, property, signal }: Props) {
+ if (!property && !signal) return null
+
+ const isFuture = match.resultType === 'FUTURE_AVAILABILITY'
+
+ const rows: FactRowProps[] = isFuture ? [
+ { icon: , label: 'Standorthinweis', value: signal?.locationHint ?? '–' },
+ { icon: , label: 'Flächenschätzung', value: signal?.areaSqmEstimate ? `~${signal.areaSqmEstimate} m²` : 'unbekannt' },
+ { icon: , label: 'Zeithorizont', value: signal?.timeHorizonMonths ? `~${signal.timeHorizonMonths} Monate` : '–' },
+ { icon: , label: 'Wahrscheinlichkeit', value: signal?.probability ? `${Math.round(signal.probability * 100)}%` : '–' },
+ ] : [
+ { icon: , label: 'Standort', value: property ? `${property.location.city}${property.location.district ? `, ${property.location.district}` : ''}` : '–' },
+ { icon: , label: 'Nutzfläche', value: property ? `${property.areaSqm} m²` : '–' },
+ { icon: , label: 'Mietpreis', value: property?.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²` : '–' },
+ { icon: , label: 'Verfügbar ab', value: property?.availabilityDate ?? '–' },
+ { icon: , label: 'Objekttyp', value: property?.assetType ?? '–' },
+ ]
+
+ return (
+
+
+
+ {isFuture ? 'Signal-Übersicht' : 'Objekt-Übersicht'}
+
+ {property?.status && (
+
+ )}
+
+ {rows.map((row, i) => (
+
+ ))}
+ {property?.description && (
+
+ {property.description}
+
+ )}
+
+ )
+}
diff --git a/src/components/match-detail/RiskPanel.tsx b/src/components/match-detail/RiskPanel.tsx
new file mode 100644
index 0000000..3f6cb73
--- /dev/null
+++ b/src/components/match-detail/RiskPanel.tsx
@@ -0,0 +1,56 @@
+import { Box, Chip, Paper, Typography } from '@mui/material'
+import { ShieldAlert } from 'lucide-react'
+import type { Match } from '../../domain/match'
+import type { Risk } from '../../domain/match'
+
+const LEVEL_ORDER = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']
+const LEVEL_META: Record = {
+ CRITICAL: { label: 'Kritisch', color: 'error', bgcolor: '#fef2f2', border: '#fca5a5' },
+ HIGH: { label: 'Hoch', color: 'error', bgcolor: '#fff7ed', border: '#fed7aa' },
+ MEDIUM: { label: 'Mittel', color: 'warning', bgcolor: '#fffbeb', border: '#fde68a' },
+ LOW: { label: 'Gering', color: 'success', bgcolor: '#f0fdf4', border: '#bbf7d0' },
+}
+
+interface Props {
+ match: Match
+}
+
+export function RiskPanel({ match }: Props) {
+ const risks: Risk[] = match.risks ?? []
+ if (risks.length === 0) return null
+
+ const sorted = [...risks].sort(
+ (a, b) => LEVEL_ORDER.indexOf(a.level) - LEVEL_ORDER.indexOf(b.level)
+ )
+
+ return (
+
+ Risiken & Unsicherheiten
+
+ {sorted.map((r, i) => {
+ const meta = LEVEL_META[r.level] ?? LEVEL_META.LOW
+ return (
+
+
+
+ {r.category}
+
+
+
+ {r.description}
+
+ {r.mitigation && (
+
+ → {r.mitigation}
+
+ )}
+
+ )
+ })}
+
+
+ )
+}
diff --git a/src/components/match-detail/ScoreBreakdownPanel.tsx b/src/components/match-detail/ScoreBreakdownPanel.tsx
new file mode 100644
index 0000000..161b0a7
--- /dev/null
+++ b/src/components/match-detail/ScoreBreakdownPanel.tsx
@@ -0,0 +1,106 @@
+import { Box, Divider, LinearProgress, Paper, Typography } from '@mui/material'
+import type { Match } from '../../domain/match'
+
+interface BreakdownRowProps {
+ label: string
+ value: number
+ max: number
+ description: string
+ color?: 'success' | 'warning' | 'error' | 'primary'
+ modifier?: boolean
+}
+
+function BreakdownRow({ label, value, max, description, color = 'primary', modifier }: BreakdownRowProps) {
+ const pct = Math.round((Math.abs(value) / max) * 100)
+ const isNegative = modifier && value < 0
+ const isPositive = modifier && value > 0
+
+ return (
+
+
+ {label}
+
+ {modifier && value > 0 ? '+' : ''}{modifier ? value : `${value}/${max}`}
+
+
+ {!modifier && (
+
+ )}
+ {description}
+
+ )
+}
+
+interface Props {
+ match: Match
+}
+
+export function ScoreBreakdownPanel({ match }: Props) {
+ const sb = match.scoreBreakdown
+
+ const hardColor: 'success' | 'warning' | 'error' =
+ sb.hardMatchScore >= 70 ? 'success' : sb.hardMatchScore >= 50 ? 'warning' : 'error'
+ const softColor: 'success' | 'warning' | 'error' =
+ sb.softFactorScore >= 70 ? 'success' : sb.softFactorScore >= 50 ? 'warning' : 'error'
+
+ return (
+
+ Score Breakdown
+
+
+
+
+
+
+
+
+
+
+
+
+ Gesamt-Score
+ = 78 ? '#1a7a4a' : sb.totalScore >= 52 ? '#d97706' : '#c0392b',
+ }}
+ >
+ {sb.totalScore}/100
+
+
+
+ )
+}
diff --git a/src/components/match-detail/SourceProvenancePanel.tsx b/src/components/match-detail/SourceProvenancePanel.tsx
new file mode 100644
index 0000000..10c6718
--- /dev/null
+++ b/src/components/match-detail/SourceProvenancePanel.tsx
@@ -0,0 +1,70 @@
+import { Box, Chip, Link, Paper, Typography } from '@mui/material'
+import type { Property } from '../../domain/property'
+
+const FRESHNESS_META: Record = {
+ FRESH: { label: 'Aktuell (< 48h)', color: 'success' },
+ STALE: { label: 'Veraltet (2–14d)', color: 'warning' },
+ OUTDATED: { label: 'Outdated (> 14d)', color: 'error' },
+}
+
+interface Props {
+ property: Property | null
+}
+
+export function SourceProvenancePanel({ property }: Props) {
+ if (!property) return null
+
+ const freshness = property.dataQuality?.freshness
+ const freshnessMeta = freshness ? (FRESHNESS_META[freshness] ?? null) : null
+ const sourceUrl = property.sourceUrl ?? property.sourceMeta?.sourceUrl
+ const sourceLabel = property.sourceLabel ?? property.sourceMeta?.sourceLabel ?? property.sourceType ?? '–'
+ const sourceUpdatedAt = property.sourceUpdatedAt ?? property.sourceMeta?.sourceUpdatedAt
+ const lastVerified = property.dataQuality?.lastVerifiedAt
+
+ const rows: { label: string; value: React.ReactNode }[] = [
+ { label: 'Quelltyp', value: property.sourceType ?? '–' },
+ {
+ label: 'Quelle',
+ value: sourceUrl ? (
+
+ {sourceLabel}
+
+ ) : (
+ {sourceLabel}
+ ),
+ },
+ ...(sourceUpdatedAt ? [{ label: 'Letzte Aktualisierung', value: {sourceUpdatedAt} }] : []),
+ ...(lastVerified ? [{ label: 'Letzte Verifikation', value: {lastVerified} }] : []),
+ ...(freshnessMeta ? [{
+ label: 'Aktualität',
+ value: ,
+ }] : []),
+ ]
+
+ const warnings = property.dataQuality?.warnings ?? []
+
+ return (
+
+ Quelle & Herkunft
+
+ {rows.map((row, i) => (
+
+
+ {row.label}
+
+ {row.value}
+
+ ))}
+
+ {warnings.length > 0 && (
+
+ {warnings.map((w, i) => (
+
+ ⚠ {w}
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/src/components/match-detail/TradeoffPanel.tsx b/src/components/match-detail/TradeoffPanel.tsx
new file mode 100644
index 0000000..a69d23f
--- /dev/null
+++ b/src/components/match-detail/TradeoffPanel.tsx
@@ -0,0 +1,56 @@
+import { Box, Chip, Paper, Typography } from '@mui/material'
+import { ArrowLeftRight } from 'lucide-react'
+import type { Match } from '../../domain/match'
+
+const SEVERITY_META: Record = {
+ HIGH: { label: 'Hoch', color: 'error' },
+ MEDIUM: { label: 'Mittel', color: 'warning' },
+ LOW: { label: 'Gering', color: 'default' },
+}
+
+interface Props {
+ match: Match
+}
+
+export function TradeoffPanel({ match }: Props) {
+ const tradeoffs = match.tradeoffs ?? []
+ if (tradeoffs.length === 0) return null
+
+ return (
+
+ Abwägungen
+
+ {tradeoffs.map((t, i) => {
+ const meta = SEVERITY_META[t.severity] ?? SEVERITY_META.LOW
+ return (
+
+
+
+
+ {t.criterion}
+
+
+
+
+ {t.concern}
+
+ {t.mitigation && (
+
+ Mitigation: {t.mitigation}
+
+ )}
+ {t.impactOnScore !== undefined && (
+
+ Score-Einfluss: {t.impactOnScore} Punkte
+
+ )}
+
+ )
+ })}
+
+
+ )
+}
diff --git a/src/components/match-detail/index.ts b/src/components/match-detail/index.ts
new file mode 100644
index 0000000..1b42c93
--- /dev/null
+++ b/src/components/match-detail/index.ts
@@ -0,0 +1,11 @@
+export { MatchDetailHeader } from './MatchDetailHeader'
+export { ExecutiveSummaryPanel } from './ExecutiveSummaryPanel'
+export { PropertyOverviewPanel } from './PropertyOverviewPanel'
+export { NeedAlignmentPanel } from './NeedAlignmentPanel'
+export { ScoreBreakdownPanel } from './ScoreBreakdownPanel'
+export { TradeoffPanel } from './TradeoffPanel'
+export { RiskPanel } from './RiskPanel'
+export { MissingInformationPanel } from './MissingInformationPanel'
+export { SourceProvenancePanel } from './SourceProvenancePanel'
+export { FutureAvailabilityContextPanel } from './FutureAvailabilityContextPanel'
+export { NextActionsPanel } from './NextActionsPanel'
diff --git a/src/components/results/UnifiedResultCard.tsx b/src/components/results/UnifiedResultCard.tsx
index 6b590e9..53e73a8 100644
--- a/src/components/results/UnifiedResultCard.tsx
+++ b/src/components/results/UnifiedResultCard.tsx
@@ -1,3 +1,4 @@
+import { useNavigate } from 'react-router'
import { MatchCardCompact } from '../match-card/MatchCardCompact'
import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter'
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
@@ -10,6 +11,7 @@ interface Props {
}
export function UnifiedResultCard({ result, isInCompare, onCompare }: Props) {
+ const navigate = useNavigate()
const isFuture = result.resultType === 'FUTURE_AVAILABILITY'
const compareId = !isFuture
? (result as { property: { id: string } }).property.id
@@ -40,8 +42,7 @@ export function UnifiedResultCard({ result, isInCompare, onCompare }: Props) {
label: 'Details',
actionType: 'OPEN_DETAIL',
variant: 'primary',
- disabled: true,
- onClick: () => {},
+ onClick: () => navigate(`/demand/results/${result.matchId}`),
},
]
diff --git a/src/pages/demand/MatchDetail.tsx b/src/pages/demand/MatchDetail.tsx
new file mode 100644
index 0000000..0332c37
--- /dev/null
+++ b/src/pages/demand/MatchDetail.tsx
@@ -0,0 +1,158 @@
+import { Box, CircularProgress, Paper, Typography } from '@mui/material'
+import { useNavigate, useParams } from 'react-router'
+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 { MatchReasonList } from '../../components/match-card/MatchReasonList'
+import {
+ MatchDetailHeader,
+ ExecutiveSummaryPanel,
+ PropertyOverviewPanel,
+ NeedAlignmentPanel,
+ ScoreBreakdownPanel,
+ TradeoffPanel,
+ RiskPanel,
+ MissingInformationPanel,
+ SourceProvenancePanel,
+ FutureAvailabilityContextPanel,
+ NextActionsPanel,
+} from '../../components/match-detail'
+import type { MatchCardReason } from '../../components/match-card/MatchCardViewModel'
+
+const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
+
+function buildReasons(match: NonNullable['data']>): MatchCardReason[] {
+ return match.positiveFactors.slice(0, 3).map(f => ({
+ type: (HARD_CRITERIA.has(f.criterion) ? 'HARD_FACT' : 'SOFT_FACTOR') as MatchCardReason['type'],
+ label: f.criterion.charAt(0).toUpperCase() + f.criterion.slice(1),
+ explanation: f.explanation,
+ score: f.score,
+ }))
+}
+
+export default function MatchDetail() {
+ const { matchId } = useParams<{ matchId: string }>()
+ const navigate = useNavigate()
+ const { addToCompare } = useCompareStore()
+
+ 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,
+ })
+
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+
+ if (!match) {
+ return (
+
+
+ Match nicht gefunden
+
+ Das gesuchte Match existiert nicht oder wurde entfernt.
+
+
+
+ )
+ }
+
+ const reasons = buildReasons(match)
+ const compareId = property?.id ?? ''
+
+ const handleCompare = () => {
+ if (compareId) addToCompare(compareId)
+ navigate('/demand/compare')
+ }
+
+ const handleBack = () => navigate(-1)
+
+ return (
+
+ {}}
+ />
+
+
+ {/* Main column */}
+
+
+
+
+
+ {/* Why It Matches — structured from scoreFactors */}
+ {reasons.length > 0 && (
+
+ Warum dieses Match
+
+ {match.negativeFactors.length > 0 && (
+
+
+ Schwächere Faktoren
+
+
+ {match.negativeFactors.slice(0, 3).map((f, i) => (
+
+ · {f.criterion}: {f.explanation}
+
+ ))}
+
+
+ )}
+
+ )}
+
+
+
+
+
+ {isFuture && }
+
+
+ {/* Sidebar — sticky */}
+
+
+ {}}
+ onReview={() => {}}
+ onReject={() => {}}
+ />
+
+
+
+ )
+}
diff --git a/src/services/matchService.ts b/src/services/matchService.ts
index 44e2305..3b799c8 100644
--- a/src/services/matchService.ts
+++ b/src/services/matchService.ts
@@ -6,6 +6,7 @@ import type { Match } from '../domain/match'
import type { Need } from '../domain/need'
import type { Property } from '../domain/property'
import type { StrongMatchItem } from '../domain/dashboard'
+import type { ScoreBreakdown } from '../domain/match'
import type { ListResponse, ItemResponse } from './types'
import { buildFullMatch, computeRankedMatches } from '../features/matching/rankingEngine'
@@ -38,6 +39,16 @@ export const matchService = {
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
+ async getMatchDetail(id: string): Promise> {
+ const data = await provider.getById(id)
+ return { data }
+ },
+
+ async getScoreBreakdown(matchId: string): Promise> {
+ const match = await provider.getById(matchId)
+ return { data: match?.scoreBreakdown ?? null }
+ },
+
// ── Engine-based methods ──────────────────────────────────────────────────
computeMatch(need: Need, property: Property): Match {