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. + + )} + + + + + + {title} + {location} + + + + {property?.assetType && ( + + )} + + + {availability && } + {source !== '–' && ( + Quelle: {source} + )} + + + + + + + + + + + + + ) +} 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 {