Files
property-match/src/components/match-card/ScoreInlineBreakdown.tsx
T
Benjamin Sutter 27c53f3af1 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>
2026-05-21 20:41:46 +02:00

135 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}