feat: F017 data quality system — trust layer across all decision surfaces

New components: DataQualityBadge, DataQualityPanel, DataQualityProgress,
FreshnessIndicator, CriticalFieldWarning, MissingDataList, ProvenancePanel
Service: calculatePropertyQuality, getMissingCriticalFields,
getQualityWarnings, getRecommendedActions with field→action mapping
PropertyDetailView: tab 5/6 now use DataQualityPanel + ProvenancePanel
Datenpflege page: DataQualityBadge, FreshnessIndicator, filter by level,
recommended action column showing highest-priority next step per property

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-17 12:51:35 +02:00
parent e0fe238c26
commit 1d0cb8b154
11 changed files with 846 additions and 293 deletions
@@ -0,0 +1,122 @@
import { Box, LinearProgress, Typography } from '@mui/material'
import { dataQualityHex } from '../../lib/utils'
import { FreshnessStatus } from '../../domain/enums'
import type { Property } from '../../domain/property'
interface Dimension {
label: string
score: number
color: string
}
function freshnessScore(f: string): number {
if (f === FreshnessStatus.FRESH) return 100
if (f === FreshnessStatus.STALE) return 50
return 15
}
function completenessScore(missingCritical: number, missingOptional: number): number {
const critPenalty = missingCritical * 15
const optPenalty = missingOptional * 5
return Math.max(0, 100 - critPenalty - optPenalty)
}
const SOURCE_PROVENANCE: Record<string, number> = {
ERP_IMPORT: 95, MANUAL_ENTRY: 90, PARTNER_FEED: 80,
IMMOSCOUT_SCRAPE: 65, HOMEGATE_SCRAPE: 65, NEWHOME_SCRAPE: 60,
MATCHOFFICE_SCRAPE: 60, MAISON_WORK_SCRAPE: 60, AI_SIGNAL: 40, UNKNOWN: 30,
}
function dimColor(score: number): string {
if (score >= 80) return '#1a7a4a'
if (score >= 55) return '#d97706'
return '#c0392b'
}
function buildDimensions(p: Property): Dimension[] {
const compScore = completenessScore(
p.dataQuality.missingCriticalFields.length,
p.dataQuality.missingOptionalFields.length,
)
const freshScore = freshnessScore(p.dataQuality.freshness)
const confScore = Math.round(p.confidenceScore * 100)
const provScore = SOURCE_PROVENANCE[p.sourceType] ?? 50
const lastVerified = p.dataQuality.lastVerifiedAt
const verScore = lastVerified
? Math.max(10, 100 - Math.floor((Date.now() - new Date(lastVerified).getTime()) / (1000 * 60 * 60 * 24)) * 2)
: 10
return [
{ label: 'Vollständigkeit', score: compScore, color: dimColor(compScore) },
{ label: 'Aktualität', score: freshScore, color: dimColor(freshScore) },
{ label: 'Vertrauensscore', score: confScore, color: dimColor(confScore) },
{ label: 'Herkunft', score: Math.min(100, provScore), color: dimColor(provScore) },
{ label: 'Verifikation', score: Math.min(100, verScore), color: dimColor(verScore) },
]
}
interface DataQualityProgressProps {
property: Property
compact?: boolean
}
export function DataQualityProgress({ property, compact = false }: DataQualityProgressProps) {
const dims = buildDimensions(property)
const overallPct = Math.round(property.dataQuality.score * 100)
const hex = dataQualityHex(property.dataQuality.score)
if (compact) {
return (
<Box sx={{ display: 'flex', gap: 0.5 }}>
{dims.map(d => (
<Box key={d.label} sx={{ flex: 1 }}>
<LinearProgress
variant="determinate"
value={d.score}
sx={{
height: 4,
borderRadius: 2,
bgcolor: '#e2e8f0',
'& .MuiLinearProgress-bar': { bgcolor: d.color },
}}
/>
</Box>
))}
</Box>
)
}
return (
<Box>
{/* Overall score header */}
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1, mb: 1.5 }}>
<Typography variant="h4" sx={{ fontWeight: 800, color: hex, lineHeight: 1 }}>
{overallPct}%
</Typography>
<Typography variant="body2" color="text.secondary">Gesamtqualität</Typography>
</Box>
{/* Dimension bars */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{dims.map(d => (
<Box key={d.label}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>{d.label}</Typography>
<Typography variant="caption" sx={{ fontWeight: 600, color: d.color, fontSize: '0.7rem' }}>{d.score}%</Typography>
</Box>
<LinearProgress
variant="determinate"
value={d.score}
sx={{
height: 5,
borderRadius: 3,
bgcolor: '#e2e8f0',
'& .MuiLinearProgress-bar': { bgcolor: d.color },
}}
/>
</Box>
))}
</Box>
</Box>
)
}