feat: score transparency on all cards + budget parser fix

- Single scoring system: MockupNeedProvider rewrites matches via calculateScore
  exclusively — allFactors always populated, no more dual-system (computeScore
  removed), weights from weightingProfile reflected in every breakdown
- ScoreInlineBreakdown: new component shows hard/soft criteria with importance
  labels (Entscheidend/Sehr wichtig/…) and formula on compact + expanded cards
- MatchCardAdapter: passes scoreBreakdown + allFactors to ViewModel
- MatchDetail: 'Zukunftssignal' label replaced with 'Future Availability'
- aiService budget parser: values < 100 treated as monthly and multiplied by 12
  to produce correct annual CHF/m²/year value — fixes 0-result searches

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-21 20:41:46 +02:00
parent 34a4dcfb29
commit 27c53f3af1
13 changed files with 1126 additions and 299 deletions
@@ -6,6 +6,7 @@ import { TradeoffList } from './TradeoffList'
import { MatchDataQualitySummary } from './MatchDataQualitySummary'
import { MatchActionToolbar } from './MatchActionToolbar'
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
import { ScoreInlineBreakdown } from './ScoreInlineBreakdown'
import type { MatchCardViewModel } from './MatchCardViewModel'
interface Props {
@@ -82,6 +83,13 @@ export function MatchCardCompact({ vm }: Props) {
<Divider sx={{ mb: 1.25 }} />
{/* Score formula — always visible */}
{vm.scoreBreakdown && (
<Box sx={{ mb: 1.25 }}>
<ScoreInlineBreakdown scoreBreakdown={vm.scoreBreakdown} allFactors={vm.allFactors} compact />
</Box>
)}
{/* Top reason only */}
{vm.reasons.length > 0 && (
<Box sx={{ mb: 1 }}>
@@ -6,6 +6,7 @@ import { TradeoffList } from './TradeoffList'
import { MatchDataQualitySummary } from './MatchDataQualitySummary'
import { MatchActionToolbar } from './MatchActionToolbar'
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
import { ScoreInlineBreakdown } from './ScoreInlineBreakdown'
import type { MatchCardViewModel } from './MatchCardViewModel'
interface Props {
@@ -59,6 +60,18 @@ export function MatchCardExpanded({ vm }: Props) {
<Divider sx={{ mb: 2 }} />
{/* Score breakdown — full criteria with weights */}
{vm.scoreBreakdown && (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.25, color: '#1e293b' }}>
Bewertungsherleitung
</Typography>
<ScoreInlineBreakdown scoreBreakdown={vm.scoreBreakdown} allFactors={vm.allFactors} />
</Box>
)}
<Divider sx={{ mb: 2 }} />
{/* Why it matches — all 3 reasons */}
{vm.reasons.length > 0 && (
<Box sx={{ mb: 2 }}>
@@ -1,5 +1,5 @@
import type { ResultType } from '../../domain/enums'
import type { TradeOff, Risk, MissingDataItem } from '../../domain/match'
import type { TradeOff, Risk, MissingDataItem, ScoreFactor } from '../../domain/match'
import type { PropertyUnit } from '../../domain/property'
export type MatchCardVariant = 'compact' | 'expanded' | 'review' | 'compare-mini'
@@ -76,6 +76,14 @@ export interface MatchCardViewModel {
preMarketUnit?: PropertyUnit // specific unit being released pre-market
preMarketAllUnits?: PropertyUnit[] // all units of the backing property
// Score transparency
scoreBreakdown?: {
hardMatchScore: number
softFactorScore: number
totalScore: number
}
allFactors?: ScoreFactor[]
// States
isSelected?: boolean
isCompareSelected?: boolean
@@ -0,0 +1,134 @@
import { Box, Divider, LinearProgress, Typography } from '@mui/material'
import type { ScoreFactor } from '../../domain/match'
interface ScoreBreakdownData {
hardMatchScore: number
softFactorScore: number
totalScore: number
}
interface Props {
scoreBreakdown: ScoreBreakdownData
allFactors?: ScoreFactor[]
compact?: boolean
}
const CRITERION_LABEL: Record<string, string> = {
area: 'Fläche', location: 'Standort', budget: 'Budget', timing: 'Verfügbarkeit',
prestige: 'Prestige', accessibility: 'Erreichbarkeit', expansionPotential: 'Expansion',
flexibility: 'Flexibilität', visibility: 'Sichtbarkeit', footfall: 'Passantenfrequenz',
talentAccess: 'Talent-Zugang', esg: 'ESG', taxEnvironment: 'Steuerumfeld',
}
const HARD_KEYS = new Set(['area', 'location', 'budget', 'timing'])
function scoreColor(v: number): 'success' | 'warning' | 'error' {
return v >= 70 ? 'success' : v >= 50 ? 'warning' : 'error'
}
function scoreTextColor(v: number): string {
return v >= 70 ? '#1a7a4a' : v >= 50 ? '#d97706' : '#c0392b'
}
function importanceLabel(weight: number, maxWeight: number): { label: string; color: string } {
const ratio = maxWeight > 0 ? weight / maxWeight : 0
if (ratio >= 0.85) return { label: 'Entscheidend', color: '#1e3a5f' }
if (ratio >= 0.65) return { label: 'Sehr wichtig', color: '#1d4ed8' }
if (ratio >= 0.40) return { label: 'Wichtig', color: '#475569' }
if (ratio >= 0.20) return { label: 'Wenig wichtig',color: '#94a3b8' }
return { label: 'Unwichtig', color: '#cbd5e1' }
}
function FactorRow({ factor, maxWeight }: { factor: ScoreFactor; maxWeight: number }) {
const label = CRITERION_LABEL[factor.criterion] ?? factor.criterion
const pct = Math.round(factor.weight * 100)
const imp = importanceLabel(factor.weight, maxWeight)
const color = scoreColor(factor.score)
return (
<Box sx={{ mb: 1.25 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.35 }}>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e293b', minWidth: 85 }}>{label}</Typography>
<Typography variant="caption" sx={{ color: imp.color, fontSize: '0.68rem', fontWeight: 500 }}>{imp.label}</Typography>
<Typography variant="caption" sx={{ color: '#cbd5e1', fontSize: '0.65rem' }}>{pct}%</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: scoreTextColor(factor.score) }}>
{factor.score}/100
</Typography>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.68rem', minWidth: 40, textAlign: 'right' }}>
{factor.contribution.toFixed(1)} Pkt
</Typography>
</Box>
</Box>
<LinearProgress variant="determinate" value={factor.score} color={color} sx={{ height: 4, borderRadius: 3, mb: 0.3 }} />
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.3, fontSize: '0.7rem' }}>
{factor.explanation}
</Typography>
</Box>
)
}
export function ScoreInlineBreakdown({ scoreBreakdown: sb, allFactors, compact = false }: Props) {
const hardContrib = Math.round(sb.hardMatchScore * 0.60 * 10) / 10
const softContrib = Math.round(sb.softFactorScore * 0.40 * 10) / 10
const formulaBox = (
<Box sx={{ bgcolor: '#f8fafc', px: 1.25, py: 0.6, borderRadius: 1 }}>
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', mb: 0.25, fontWeight: 500, fontSize: '0.68rem' }}>
Berechnung
</Typography>
<Typography variant="caption" sx={{ fontFamily: 'monospace', color: '#1e293b', display: 'block', lineHeight: 1.5 }}>
Hart {sb.hardMatchScore} × 60% + Soft {sb.softFactorScore} × 40%
{' = '}{hardContrib} + {softContrib}{' = '}
<span style={{ fontWeight: 800, color: scoreTextColor(sb.totalScore) }}>{sb.totalScore}</span>
</Typography>
</Box>
)
// Compact: only the formula line
if (compact || !allFactors || allFactors.length === 0) {
return formulaBox
}
// Full: all criteria + formula
const hardFactors = allFactors.filter(f => HARD_KEYS.has(f.criterion))
const softFactors = allFactors.filter(f => !HARD_KEYS.has(f.criterion))
const maxHardWeight = Math.max(...hardFactors.map(f => f.weight), 0.001)
const maxSoftWeight = Math.max(...softFactors.map(f => f.weight), 0.001)
return (
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1e3a5f', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 1 }}>
Hart-Kriterien
</Typography>
{hardFactors.map((f, i) => <FactorRow key={i} factor={f} maxWeight={maxHardWeight} />)}
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#eef2ff', px: 1.25, py: 0.6, borderRadius: 1, mb: softFactors.length > 0 ? 2 : 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e3a5f' }}>
{sb.hardMatchScore}/100 × 60%
</Typography>
<Typography variant="caption" sx={{ fontWeight: 800, color: '#1e3a5f' }}>{hardContrib} Pkt</Typography>
</Box>
{softFactors.length > 0 && (
<>
<Divider sx={{ mb: 1.5 }} />
<Typography variant="caption" sx={{ fontWeight: 700, color: '#475569', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 1 }}>
Soft-Faktoren
</Typography>
{softFactors.map((f, i) => <FactorRow key={i} factor={f} maxWeight={maxSoftWeight} />)}
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#f8fafc', px: 1.25, py: 0.6, borderRadius: 1, mb: 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569' }}>
{sb.softFactorScore}/100 × 40%
</Typography>
<Typography variant="caption" sx={{ fontWeight: 800, color: '#475569' }}>{softContrib} Pkt</Typography>
</Box>
</>
)}
<Divider sx={{ mb: 1 }} />
{formulaBox}
</Box>
)
}
@@ -1,6 +1,7 @@
import { Box, Divider, LinearProgress, Link, Paper, Typography } from '@mui/material'
import { CheckCircle2, ExternalLink, ShieldCheck, X } from 'lucide-react'
import type { Match } from '../../domain/match'
import { CheckCircle2, ShieldCheck, X } from 'lucide-react'
import { ExternalLink } from 'lucide-react'
import type { Match, ScoreFactor } from '../../domain/match'
import type { FutureSignal } from '../../domain/futureSignal'
// ── Shared helpers ─────────────────────────────────────────────────────────────
@@ -9,49 +10,205 @@ function scoreColor(v: number): 'success' | 'warning' | 'error' {
return v >= 70 ? 'success' : v >= 50 ? 'warning' : 'error'
}
function scoreTextColor(v: number): string {
return v >= 70 ? '#1a7a4a' : v >= 50 ? '#d97706' : '#c0392b'
}
const CREDIBILITY_LABELS: Record<string, string> = {
HIGH: 'Hohe Quellenqualität',
MEDIUM: 'Mittlere Quellenqualität',
LOW: 'Niedrige Quellenqualität',
}
// ── Standard breakdown (VERIFIED_PORTFOLIO / EXTERNAL / MAISON) ──────────────
interface BreakdownRowProps {
label: string
value: number
max: number
description: string
color?: 'success' | 'warning' | 'error' | 'primary'
modifier?: boolean
const CRITERION_LABEL: Record<string, string> = {
area: 'Fläche', location: 'Standort', budget: 'Budget', timing: 'Verfügbarkeit',
prestige: 'Prestige', accessibility: 'Erreichbarkeit', expansionPotential: 'Expansion',
flexibility: 'Flexibilität', visibility: 'Sichtbarkeit', footfall: 'Passantenfrequenz',
talentAccess: 'Talent-Zugang', esg: 'ESG / Nachhaltigkeit', taxEnvironment: 'Steuerumfeld',
}
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
const HARD_KEYS = new Set(['area', 'location', 'budget', 'timing'])
function factorLabel(criterion: string): string {
return CRITERION_LABEL[criterion] ?? criterion
}
// Convert normalised weight to 1-5 importance level relative to other factors in the same set
function importanceLabel(weight: number, maxWeight: number): { label: string; color: string } {
const ratio = maxWeight > 0 ? weight / maxWeight : 0
if (ratio >= 0.85) return { label: 'Entscheidend', color: '#1e3a5f' }
if (ratio >= 0.65) return { label: 'Sehr wichtig', color: '#1d4ed8' }
if (ratio >= 0.40) return { label: 'Wichtig', color: '#475569' }
if (ratio >= 0.20) return { label: 'Wenig wichtig',color: '#94a3b8' }
return { label: 'Unwichtig', color: '#cbd5e1' }
}
// ── Single criterion row ───────────────────────────────────────────────────────
function CriterionRow({ factor, maxWeight }: { factor: ScoreFactor; maxWeight: number }) {
const label = factorLabel(factor.criterion)
const pct = Math.round(factor.weight * 100)
const color = scoreColor(factor.score)
const imp = importanceLabel(factor.weight, maxWeight)
return (
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>{label}</Typography>
<Typography
variant="body2"
sx={{ fontWeight: 700, color: isNegative ? '#c0392b' : isPositive ? '#1a7a4a' : 'text.primary' }}
>
{modifier && value > 0 ? '+' : ''}{modifier ? value : `${value}/${max}`}
<Box sx={{ mb: 1.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.4 }}>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e293b', minWidth: 90 }}>{label}</Typography>
<Typography variant="caption" sx={{ color: imp.color, fontSize: '0.68rem', fontWeight: 500 }}>
{imp.label}
</Typography>
<Typography variant="caption" sx={{ color: '#cbd5e1', fontSize: '0.65rem' }}>
{pct}%
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: scoreTextColor(factor.score) }}>
{factor.score}/100
</Typography>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem', minWidth: 44, textAlign: 'right' }}>
{factor.contribution.toFixed(1)} Pkt
</Typography>
</Box>
</Box>
<LinearProgress
variant="determinate"
value={factor.score}
color={color}
sx={{ height: 5, borderRadius: 3, mb: 0.4 }}
/>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.3 }}>
{factor.explanation}
</Typography>
</Box>
)
}
// ── Standard breakdown (VERIFIED_PORTFOLIO / EXTERNAL / MAISON) ──────────────
interface StandardBreakdownProps {
match: Match
taxCalculatorUrl?: string
}
function StandardBreakdown({ match, taxCalculatorUrl }: StandardBreakdownProps) {
const sb = match.scoreBreakdown
// Use allFactors when available (typed English keys); fall back to pos + neg
const hasAllFactors = !!match.allFactors
const allFactors = match.allFactors ?? [...match.positiveFactors, ...(match.negativeFactors ?? [])]
const hardFactors = hasAllFactors
? allFactors.filter(f => HARD_KEYS.has(f.criterion))
: allFactors // static mock data: show flat, no grouping
const softFactors = hasAllFactors
? allFactors.filter(f => !HARD_KEYS.has(f.criterion))
: []
const hardContrib = Math.round(sb.hardMatchScore * 0.60 * 10) / 10
const softContrib = Math.round(sb.softFactorScore * 0.40 * 10) / 10
const maxHardWeight = Math.max(...hardFactors.map(f => f.weight), 0.001)
const maxSoftWeight = Math.max(...softFactors.map(f => f.weight), 0.001)
return (
<>
{/* Hard criteria */}
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1e3a5f', textTransform: 'uppercase', letterSpacing: 0.6, display: 'block', mb: 1.25 }}>
{hasAllFactors ? 'Hart-Kriterien' : 'Bewertungsfaktoren'}
</Typography>
{hardFactors.map((f, i) => <CriterionRow key={i} factor={f} maxWeight={maxHardWeight} />)}
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#eef2ff', px: 1.25, py: 0.75, borderRadius: 1, mt: 0.25, mb: hasAllFactors && softFactors.length > 0 ? 2 : 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e3a5f' }}>
Hart-Kriterien {sb.hardMatchScore}/100 × 60%
</Typography>
<Typography variant="caption" sx={{ fontWeight: 800, color: '#1e3a5f' }}>
{hardContrib} Pkt
</Typography>
</Box>
{!modifier && (
<LinearProgress
variant="determinate"
value={Math.min(pct, 100)}
sx={{ height: 8, borderRadius: 4, mb: 0.5 }}
color={color}
/>
{/* Soft factors: full list when allFactors present, otherwise summary row */}
{!hasAllFactors && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#f8fafc', px: 1.25, py: 0.75, borderRadius: 1, mb: 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569' }}>
Soft-Faktoren {sb.softFactorScore}/100 × 40%
</Typography>
<Typography variant="caption" sx={{ fontWeight: 800, color: '#475569' }}>
{softContrib} Pkt
</Typography>
</Box>
)}
<Typography variant="caption" color="text.secondary">{description}</Typography>
</Box>
{hasAllFactors && softFactors.length > 0 && (
<>
<Divider sx={{ mb: 2 }} />
<Typography variant="caption" sx={{ fontWeight: 700, color: '#475569', textTransform: 'uppercase', letterSpacing: 0.6, display: 'block', mb: 1.25 }}>
Soft-Faktoren
</Typography>
{softFactors.map((f, i) => <CriterionRow key={i} factor={f} maxWeight={maxSoftWeight} />)}
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#f8fafc', px: 1.25, py: 0.75, borderRadius: 1, mt: 0.25, mb: 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569' }}>
Soft-Score {sb.softFactorScore}/100 × 40%
</Typography>
<Typography variant="caption" sx={{ fontWeight: 800, color: '#475569' }}>
{softContrib} Pkt
</Typography>
</Box>
</>
)}
{/* Tax link */}
{taxCalculatorUrl && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5, p: 1.25, bgcolor: '#f0f9ff', borderRadius: 1, border: '1px solid #bae6fd' }}>
<ExternalLink size={13} color="#0369a1" style={{ flexShrink: 0 }} />
<Box>
<Typography variant="caption" sx={{ color: '#0369a1', fontWeight: 600, display: 'block', lineHeight: 1.2 }}>
Steuerlast in Bewertung eingeflossen
</Typography>
<Link
href={taxCalculatorUrl}
target="_blank"
rel="noopener noreferrer"
underline="hover"
sx={{ fontSize: '0.72rem', color: '#0369a1' }}
>
Steuerrechner Gemeinde öffnen
</Link>
</Box>
</Box>
)}
<Divider sx={{ mb: 1.5 }} />
{/* Formula row — always visible */}
<Box sx={{ bgcolor: '#f8fafc', px: 1.25, py: 0.75, borderRadius: 1, mb: 1.25 }}>
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', mb: 0.4, fontWeight: 500 }}>
Berechnung
</Typography>
<Typography variant="caption" sx={{ fontFamily: 'monospace', color: '#1e293b', display: 'block', lineHeight: 1.6 }}>
Hart {sb.hardMatchScore} × 60% + Soft {sb.softFactorScore} × 40%
{' = '}{hardContrib} + {softContrib} = {sb.totalScore}
</Typography>
</Box>
{/* Total */}
<Box sx={{
display: 'flex', alignItems: 'baseline', justifyContent: 'space-between',
bgcolor: sb.totalScore >= 78 ? '#f0fdf4' : sb.totalScore >= 52 ? '#fffbeb' : '#fef2f2',
p: 1.5, borderRadius: 1,
}}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Gesamt-Score</Typography>
<Typography
variant="h4"
sx={{ fontWeight: 800, color: scoreTextColor(sb.totalScore) }}
>
{sb.totalScore}/100
</Typography>
</Box>
</>
)
}
@@ -68,11 +225,15 @@ function SignalScoreDerivation({ match, signal }: SignalScoreDerivationProps) {
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
// Use allFactors or fall back to pos + neg
const displayFactors = match.allFactors
? match.allFactors
: [...match.positiveFactors, ...(match.negativeFactors ?? [])]
return (
<>
{/* Schritt 1 — Kriterien-Match */}
@@ -84,47 +245,32 @@ function SignalScoreDerivation({ match, signal }: SignalScoreDerivationProps) {
Wie gut passt die Fläche, wenn das Signal eintrifft?
</Typography>
{match.positiveFactors.map((f, i) => (
<Box key={i} sx={{ mb: 1.25 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, mb: 0.4 }}>
<CheckCircle2 size={12} color="#1a7a4a" style={{ flexShrink: 0 }} />
<Box sx={{ flex: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e293b' }}>{f.criterion}</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1a7a4a', ml: 1 }}>{f.score}/100</Typography>
{displayFactors.map((f, i) => {
const isPositive = f.score >= 70
return (
<Box key={i} sx={{ mb: 1.25 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, mb: 0.4 }}>
{isPositive
? <CheckCircle2 size={12} color="#1a7a4a" style={{ flexShrink: 0 }} />
: <X size={12} color="#c0392b" style={{ flexShrink: 0 }} />
}
<Box sx={{ flex: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e293b' }}>{factorLabel(f.criterion)}</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: scoreTextColor(f.score), ml: 1 }}>{f.score}/100</Typography>
</Box>
</Box>
<LinearProgress
variant="determinate"
value={f.score}
color={scoreColor(f.score)}
sx={{ height: 5, borderRadius: 3, mb: 0.35, ml: 2.25 }}
/>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', ml: 2.25, lineHeight: 1.3 }}>
{f.explanation}
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={f.score}
color={scoreColor(f.score)}
sx={{ height: 5, borderRadius: 3, mb: 0.35, ml: 2.25 }}
/>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', ml: 2.25, lineHeight: 1.3 }}>
{f.explanation}
</Typography>
</Box>
))}
{(match.negativeFactors ?? []).map((f, i) => (
<Box key={i} sx={{ mb: 1.25 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, mb: 0.4 }}>
<X size={12} color="#c0392b" style={{ flexShrink: 0 }} />
<Box sx={{ flex: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e293b' }}>{f.criterion}</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#c0392b', ml: 1 }}>{f.score}/100</Typography>
</Box>
</Box>
<LinearProgress
variant="determinate"
value={f.score}
color="error"
sx={{ height: 5, borderRadius: 3, mb: 0.35, ml: 2.25 }}
/>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', ml: 2.25, lineHeight: 1.3 }}>
{f.explanation}
</Typography>
</Box>
))}
)
})}
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#f8fafc', px: 1.25, py: 0.75, borderRadius: 1, mt: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569' }}>Basis-Score</Typography>
@@ -188,10 +334,7 @@ function SignalScoreDerivation({ match, signal }: SignalScoreDerivationProps) {
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Gesamt-Score</Typography>
<Typography
variant="h4"
sx={{
fontWeight: 800,
color: sb.totalScore >= 78 ? '#1a7a4a' : sb.totalScore >= 52 ? '#d97706' : '#c0392b',
}}
sx={{ fontWeight: 800, color: scoreTextColor(sb.totalScore) }}
>
{sb.totalScore}/100
</Typography>
@@ -210,8 +353,6 @@ interface Props {
}
export function ScoreBreakdownPanel({ match, taxCalculatorUrl, isFuture, signal }: Props) {
const sb = match.scoreBreakdown
if (isFuture) {
return (
<Paper sx={{ p: 2.5, mb: 2 }}>
@@ -221,79 +362,10 @@ export function ScoreBreakdownPanel({ match, taxCalculatorUrl, isFuture, signal
)
}
const hardColor = scoreColor(sb.hardMatchScore)
const softColor = scoreColor(sb.softFactorScore)
return (
<Paper sx={{ p: 2.5, mb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 2 }}>Score Breakdown</Typography>
<BreakdownRow
label="Hard-Kriterien (60%)"
value={sb.hardMatchScore}
max={100}
description="Fläche, Standort, Budget, Timing"
color={hardColor}
/>
<BreakdownRow
label="Soft Factors (40%)"
value={sb.softFactorScore}
max={100}
description="Prestige, Erreichbarkeit, ESG, Steuerlast"
color={softColor}
/>
{taxCalculatorUrl && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5, p: 1.25, bgcolor: '#f0f9ff', borderRadius: 1, border: '1px solid #bae6fd' }}>
<ExternalLink size={13} color="#0369a1" style={{ flexShrink: 0 }} />
<Box>
<Typography variant="caption" sx={{ color: '#0369a1', fontWeight: 600, display: 'block', lineHeight: 1.2 }}>
KI hat Steuerlast bewertet
</Typography>
<Link
href={taxCalculatorUrl}
target="_blank"
rel="noopener noreferrer"
underline="hover"
sx={{ fontSize: '0.72rem', color: '#0369a1' }}
>
Steuerrechner Gemeinde öffnen
</Link>
</Box>
</Box>
)}
<Divider sx={{ my: 1.5 }} />
<BreakdownRow
label="Datenqualitäts-Modifier"
value={sb.dataQualityModifier}
max={15}
description="Basierend auf Vollständigkeit und Aktualität der Objektdaten"
modifier
/>
<BreakdownRow
label="Konfidenz-Modifier"
value={sb.confidenceModifier}
max={15}
description="Basierend auf Quelltyp (Verified/Extern/Signal)"
modifier
/>
<Divider sx={{ my: 1.5 }} />
<Box sx={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', bgcolor: '#f8fafc', p: 1.5, borderRadius: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Gesamt-Score</Typography>
<Typography
variant="h4"
sx={{
fontWeight: 800,
color: sb.totalScore >= 78 ? '#1a7a4a' : sb.totalScore >= 52 ? '#d97706' : '#c0392b',
}}
>
{sb.totalScore}/100
</Typography>
</Box>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 2 }}>Bewertungsherleitung</Typography>
<StandardBreakdown match={match} taxCalculatorUrl={taxCalculatorUrl} />
</Paper>
)
}