diff --git a/src/components/match-card/IntelligenceMatchCard.tsx b/src/components/match-card/IntelligenceMatchCard.tsx index 74ee9c5..4146fbc 100644 --- a/src/components/match-card/IntelligenceMatchCard.tsx +++ b/src/components/match-card/IntelligenceMatchCard.tsx @@ -258,7 +258,6 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro const tier = getScoreTier(vm.matchScore) const theme = SCORE_THEME[tier] const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' } - const topReason = vm.reasons[0] const isFuture = vm.resultType === 'FUTURE_AVAILABILITY' return ( @@ -360,14 +359,23 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro )} - {topReason && ( + {vm.reasons.length > 0 && ( <> - - - - {topReason.label} - + + {vm.reasons.slice(0, 3).map((r, i) => ( + + + + + {r.label} + + + {r.explanation} + + + + ))} )} diff --git a/src/components/match-detail/FutureAvailabilityContextPanel.tsx b/src/components/match-detail/FutureAvailabilityContextPanel.tsx index 09c251d..ae1818b 100644 --- a/src/components/match-detail/FutureAvailabilityContextPanel.tsx +++ b/src/components/match-detail/FutureAvailabilityContextPanel.tsx @@ -1,4 +1,4 @@ -import { Alert, Box, Button, Chip, Divider, Paper, Typography } from '@mui/material' +import { Box, Button, Chip, Divider, Paper, Typography } from '@mui/material' import { BarChart2, Briefcase, @@ -20,6 +20,7 @@ const SIGNAL_TYPE_LABELS: Record = { RESTRUCTURING: 'Restrukturierung', PROJECT_DEVELOPMENT: 'Projektentwicklung', SPACE_CONSOLIDATION: 'Flächenkonsolidierung', + LEASE_EXPIRY: 'Vertragsende (Schattenmarkt)', } const SIGNAL_RELEVANCE_BRIDGE: Record = { @@ -29,6 +30,7 @@ const SIGNAL_RELEVANCE_BRIDGE: Record = { RESTRUCTURING: 'Restrukturierung deutet auf Flächenänderungen hin.', PROJECT_DEVELOPMENT: 'Projektentwicklung könnte neue Gewerbeflächen schaffen.', SPACE_CONSOLIDATION: 'Konsolidierung — Teilflächen könnten frei werden.', + LEASE_EXPIRY: 'Die Verwaltung hat dieses Objekt für den Markt freigegeben — Vertragsende bekannt und bestätigt.', } const SOURCE_META: Record = { @@ -38,6 +40,7 @@ const SOURCE_META: Record = { COMPANY_REPORT: { label: 'Geschäftsbericht', icon: }, MARKET_DATA: { label: 'Marktdaten', icon: }, MANUAL: { label: 'Analyst', icon: }, + LEASE_CONTRACT: { label: 'Vertrag verifiziert (ERP)', icon: }, } const CREDIBILITY_META: Record = { @@ -65,11 +68,11 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props) const sensitiveMeta = SENSITIVITY_META[signal.sensitivityLevel] ?? SENSITIVITY_META.PUBLIC const relevanceBridge = signal.signalType ? (SIGNAL_RELEVANCE_BRIDGE[signal.signalType] ?? null) : null const probPct = Math.round(signal.probability * 100) - const confPct = Math.round(signal.confidenceScore * 100) + const isVerifiedContract = signal.isVerified && signal.source.type === 'LEASE_CONTRACT' return ( - + Schattenmarkt-Signal {signal.isVerified && ( @@ -80,23 +83,87 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props) )} - {/* Mandatory disclaimer */} - - {signal.disclaimer} - + {/* 1. Quelle & Verlässlichkeit — FIRST */} + + Quelle & Verlässlichkeit - {/* Relevance bridge */} - {relevanceBridge && ( - - - - Warum erscheint dieses Signal in Ihrer Suche? - {relevanceBridge} - + + {sourceMeta && ( + + {sourceMeta.icon} + {sourceMeta.label} + + )} + {credMeta && ( + + + {credMeta.label} + + )} + {signal.source.publishedAt && ( + + Publiziert: {new Date(signal.source.publishedAt).toLocaleDateString('de-CH', { day: '2-digit', month: 'long', year: 'numeric' })} + + )} - )} - {/* Key metrics row */} + {isVerifiedContract && ( + + + + Vertragsende aus internem ERP bestätigt + + + + Verwaltung hat Marktfreigabe erteilt + + + )} + + {signal.source.url && ( + + + + )} + + {signal.evidence?.sourceUrls && signal.evidence.sourceUrls.length > 0 && ( + + + Weitere Belege ({signal.evidence.sourceUrls.length}) + + + {signal.evidence.sourceUrls.map((url, i) => ( + + + {url} + + ))} + + + )} + + + + + {/* 2. Signal-Kenndaten */} {signal.signalType && ( @@ -108,10 +175,6 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props) Wahrscheinlichkeit = 70 ? '#1a7a4a' : probPct >= 50 ? '#d97706' : '#c0392b' }}>{probPct}% - - Konfidenz - {confPct}% - Zeithorizont ~{signal.timeHorizonMonths} Monate @@ -122,15 +185,11 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props) ~{signal.areaSqmEstimate.toLocaleString('de-CH')} m² )} - - Vertraulichkeit - - - {/* AI Summary */} + {/* 3. KI-Analyse */} {signal.aiSummary && ( @@ -145,7 +204,18 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props) )} - {/* Evidence */} + {/* 4. Relevance bridge */} + {relevanceBridge && ( + + + + Warum erscheint dieses Signal in Ihrer Suche? + {relevanceBridge} + + + )} + + {/* 5. Evidence */} {signal.evidence?.summary && ( Evidenz @@ -155,66 +225,16 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props) )} - - - {/* Source section */} - Quelle - - {sourceMeta && ( - - {sourceMeta.icon} - {sourceMeta.label} - - )} - {credMeta && ( - - - {credMeta.label} - - )} - {signal.source.publishedAt && ( - - Publiziert: {new Date(signal.source.publishedAt).toLocaleDateString('de-CH', { day: '2-digit', month: 'long', year: 'numeric' })} - - )} - {signal.source.url && ( - - )} + {/* 6. Vertraulichkeit */} + + Vertraulichkeit: + - {signal.evidence?.sourceUrls && signal.evidence.sourceUrls.length > 0 && ( - - - Weitere Belege ({signal.evidence.sourceUrls.length}) - - - {signal.evidence.sourceUrls.map((url, i) => ( - - - {url} - - ))} - - - )} + {/* 7. Disclaimer — grey footnote */} + + {signal.disclaimer} + ) } diff --git a/src/components/match-detail/ScoreBreakdownPanel.tsx b/src/components/match-detail/ScoreBreakdownPanel.tsx index c4429de..38e45a8 100644 --- a/src/components/match-detail/ScoreBreakdownPanel.tsx +++ b/src/components/match-detail/ScoreBreakdownPanel.tsx @@ -1,6 +1,21 @@ import { Box, Divider, LinearProgress, Link, Paper, Typography } from '@mui/material' -import { ExternalLink } from 'lucide-react' +import { CheckCircle2, ExternalLink, ShieldCheck, X } from 'lucide-react' import type { Match } from '../../domain/match' +import type { FutureSignal } from '../../domain/futureSignal' + +// ── Shared helpers ───────────────────────────────────────────────────────────── + +function scoreColor(v: number): 'success' | 'warning' | 'error' { + return v >= 70 ? 'success' : v >= 50 ? 'warning' : 'error' +} + +const CREDIBILITY_LABELS: Record = { + HIGH: 'Hohe Quellenqualität', + MEDIUM: 'Mittlere Quellenqualität', + LOW: 'Niedrige Quellenqualität', +} + +// ── Standard breakdown (VERIFIED_PORTFOLIO / EXTERNAL / MAISON) ────────────── interface BreakdownRowProps { label: string @@ -40,18 +55,174 @@ function BreakdownRow({ label, value, max, description, color = 'primary', modif ) } +// ── Signal score derivation (FUTURE_AVAILABILITY) ───────────────────────────── + +interface SignalScoreDerivationProps { + match: Match + signal: FutureSignal | null +} + +function SignalScoreDerivation({ match, signal }: SignalScoreDerivationProps) { + const sb = match.scoreBreakdown + const isVerifiedContract = signal?.isVerified && signal?.source?.type === 'LEASE_CONTRACT' + const probPct = signal ? Math.round(signal.probability * 100) : null + const credLabel = signal ? (CREDIBILITY_LABELS[signal.source.credibility] ?? signal.source.credibility) : null + + // Approximate signal deduction for display purposes + const signalDeduction = signal && !isVerifiedContract + ? Math.round((1 - signal.probability) * sb.hardMatchScore * 0.18) + : 0 + + return ( + <> + {/* Schritt 1 — Kriterien-Match */} + + + Schritt 1 — Kriterien-Match + + + Wie gut passt die Fläche, wenn das Signal eintrifft? + + + {match.positiveFactors.map((f, i) => ( + + + + + {f.criterion} + {f.score}/100 + + + + + {f.explanation} + + + ))} + + {(match.negativeFactors ?? []).map((f, i) => ( + + + + + {f.criterion} + {f.score}/100 + + + + + {f.explanation} + + + ))} + + + Basis-Score + {sb.hardMatchScore}/100 + + + + + + {/* Schritt 2 — Verlässlichkeit */} + + + Schritt 2 — Verlässlichkeit des Signals + + + {isVerifiedContract ? ( + <> + + + + Vertragsende aus ERP verifiziert — keine Schätzung + + + + + + Verwaltung hat Freigabe erteilt + + + + Signal-Abschlag + keiner + + + ) : ( + <> + {probPct !== null && ( + + Eintretenswahrscheinlichkeit: {probPct}% + –{Math.round(signalDeduction * 0.7)} Pkt. + + )} + {credLabel && ( + + Quellenqualität: {credLabel} + –{Math.round(signalDeduction * 0.3)} Pkt. + + )} + + Signal-Abschlag + –{signalDeduction} Punkte + + + )} + + + + + {/* Gesamt */} + + Gesamt-Score + = 78 ? '#1a7a4a' : sb.totalScore >= 52 ? '#d97706' : '#c0392b', + }} + > + {sb.totalScore}/100 + + + + ) +} + +// ── Public component ─────────────────────────────────────────────────────────── + interface Props { match: Match taxCalculatorUrl?: string + isFuture?: boolean + signal?: FutureSignal | null } -export function ScoreBreakdownPanel({ match, taxCalculatorUrl }: Props) { +export function ScoreBreakdownPanel({ match, taxCalculatorUrl, isFuture, signal }: 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' + if (isFuture) { + return ( + + Bewertungsherleitung + + + ) + } + + const hardColor = scoreColor(sb.hardMatchScore) + const softColor = scoreColor(sb.softFactorScore) return ( diff --git a/src/features/matching/matchCardAdapter.ts b/src/features/matching/matchCardAdapter.ts index 5dabf18..0cd60fe 100644 --- a/src/features/matching/matchCardAdapter.ts +++ b/src/features/matching/matchCardAdapter.ts @@ -15,29 +15,12 @@ import { getCityIntelligence } from '../../lib/locationIntelligence' const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing']) function buildReasons(positiveFactors: ScoreFactor[]): MatchCardReason[] { - const reasons: MatchCardReason[] = [] - - const hardFact = positiveFactors.find(f => HARD_CRITERIA.has(f.criterion)) - const softFact = positiveFactors.find(f => !HARD_CRITERIA.has(f.criterion)) - - if (hardFact) { - reasons.push({ - type: 'HARD_FACT', - label: capitalize(hardFact.criterion), - explanation: hardFact.explanation, - score: hardFact.score, - }) - } - if (softFact) { - reasons.push({ - type: 'SOFT_FACTOR', - label: capitalize(softFact.criterion), - explanation: softFact.explanation, - score: softFact.score, - }) - } - - return reasons + return positiveFactors.slice(0, 3).map(f => ({ + type: (HARD_CRITERIA.has(f.criterion) ? 'HARD_FACT' : 'SOFT_FACTOR') as MatchCardReason['type'], + label: capitalize(f.criterion), + explanation: f.explanation, + score: f.score, + })) } function capitalize(s: string): string { diff --git a/src/pages/demand/MatchDetail.tsx b/src/pages/demand/MatchDetail.tsx index 30fa618..c0c0b50 100644 --- a/src/pages/demand/MatchDetail.tsx +++ b/src/pages/demand/MatchDetail.tsx @@ -45,12 +45,6 @@ function buildReasons(match: NonNullable['data })) } -function confColor(c: number) { - return c >= 0.75 ? '#1a7a4a' : c >= 0.55 ? '#d97706' : '#c0392b' -} -function dqColor(q: number) { - return q >= 0.80 ? '#1a7a4a' : q >= 0.60 ? '#d97706' : '#c0392b' -} export default function MatchDetail() { const { matchId } = useParams<{ matchId: string }>() @@ -108,10 +102,6 @@ export default function MatchDetail() { const location = city ? `${city}${property?.location?.district ? `, ${property.location.district}` : ''}` : signal?.locationHint ?? '–' - const dqScore = property?.dataQuality?.score ?? signal?.confidenceScore ?? 0.5 - const confPct = Math.round(match.confidenceLevel * 100) - const dqPct = Math.round(dqScore * 100) - // Tax calculator — only shown when AI evaluated taxEnvironment const allFactors = [...match.positiveFactors, ...(match.negativeFactors ?? [])] const hasTaxFactor = allFactors.some(f => f.criterion === 'taxEnvironment') @@ -223,16 +213,6 @@ export default function MatchDetail() { {property?.assetType && ( )} - - @@ -343,7 +323,7 @@ export default function MatchDetail() { {/* Sidebar */} - +