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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user