27c53f3af1
- 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>
135 lines
6.1 KiB
TypeScript
135 lines
6.1 KiB
TypeScript
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>
|
||
)
|
||
}
|