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
+116 -186
View File
@@ -1,28 +1,27 @@
import { useState } from 'react'
import {
Alert,
Box,
Card,
Chip,
LinearProgress,
MenuItem,
Select,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Tooltip,
Typography,
} from '@mui/material'
import { useQuery } from '@tanstack/react-query'
import { ErrorState, LoadingPage, SectionContainer } from '../../components/ui'
import { DataQualityBadge, FreshnessIndicator } from '../../components/data-quality'
import { propertyService } from '../../services/propertyService'
import { DataFreshness, ResultType } from '../../domain/enums'
import { getRecommendedActions } from '../../services/dataQualityService'
import { DataFreshness } from '../../domain/enums'
function getResultTypeLabel(type: ResultType): string {
switch (type) {
case ResultType.VERIFIED_PORTFOLIO: return 'Verified Portfolio'
case ResultType.EXTERNAL_MARKET: return 'Marktinserat'
case ResultType.FUTURE_AVAILABILITY: return 'Zukunftssignal'
}
}
type QualityFilter = '' | 'HIGH' | 'MEDIUM' | 'LOW' | 'INCOMPLETE'
function getQualityColor(score: number): 'success' | 'warning' | 'error' {
if (score >= 0.8) return 'success'
@@ -30,23 +29,9 @@ function getQualityColor(score: number): 'success' | 'warning' | 'error' {
return 'error'
}
function getFreshnessLabel(freshness: DataFreshness): string {
switch (freshness) {
case DataFreshness.FRESH: return 'Aktuell'
case DataFreshness.STALE: return 'Veraltet'
case DataFreshness.OUTDATED: return 'Abgelaufen'
}
}
function getFreshnessColor(freshness: DataFreshness): 'success' | 'warning' | 'error' {
switch (freshness) {
case DataFreshness.FRESH: return 'success'
case DataFreshness.STALE: return 'warning'
case DataFreshness.OUTDATED: return 'error'
}
}
export default function DataQuality() {
const [qualityFilter, setQualityFilter] = useState<QualityFilter>('')
const { data: resp, isLoading, error } = useQuery({
queryKey: ['properties'],
queryFn: () => propertyService.getAll(),
@@ -63,234 +48,179 @@ export default function DataQuality() {
const criticalIssues = properties.filter(p => p.dataQuality.missingCriticalFields.length > 0)
const staleData = properties.filter(
p => p.dataQuality.freshness === DataFreshness.STALE || p.dataQuality.freshness === DataFreshness.OUTDATED
p => p.dataQuality.freshness === DataFreshness.STALE || p.dataQuality.freshness === DataFreshness.OUTDATED,
)
const highQuality = properties.filter(p => p.dataQuality.score >= 0.8)
const medQuality = properties.filter(p => p.dataQuality.score >= 0.6 && p.dataQuality.score < 0.8)
const lowQuality = properties.filter(p => p.dataQuality.score < 0.6)
// Sort by score ascending (worst first)
const sortedProperties = [...properties].sort((a, b) => a.dataQuality.score - b.dataQuality.score)
const filtered = [...properties]
.filter(p => {
if (!qualityFilter) return true
if (qualityFilter === 'INCOMPLETE') return p.dataQuality.missingCriticalFields.length > 0
if (qualityFilter === 'HIGH') return p.dataQuality.score >= 0.8 && p.dataQuality.missingCriticalFields.length === 0
if (qualityFilter === 'MEDIUM') return p.dataQuality.score >= 0.6 && p.dataQuality.score < 0.8
if (qualityFilter === 'LOW') return p.dataQuality.score < 0.6
return true
})
.sort((a, b) => a.dataQuality.score - b.dataQuality.score)
return (
<Box>
{/* Page Header */}
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid', borderColor: 'divider', px: 3, py: 2 }}>
<Typography variant="h5" sx={{ fontWeight: 700 }} color="text.primary">Datenqualität</Typography>
<Typography variant="body2" color="text.secondary">Vollständigkeit und Aktualität der Objektdaten</Typography>
<Typography variant="h5" sx={{ fontWeight: 700 }} color="text.primary">Datenpflege</Typography>
<Typography variant="body2" color="text.secondary">Vollständigkeit, Aktualität und Vertrauen der Objektdaten</Typography>
</Box>
{/* Content */}
<Box sx={{ px: 3, py: 3 }} className="flex flex-col gap-4">
{/* Summary Stats Row */}
{/* Summary Stats */}
<Box className="grid grid-cols-3 gap-4">
{/* Avg Quality Score */}
<Card sx={{ elevation: 1, p: 2.5 }}>
<Card sx={{ p: 2.5 }}>
<Typography variant="overline" color="text.secondary" sx={{ display: 'block' }}>Ø Qualitätsscore</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: avgScore >= 0.8 ? '#1a7a4a' : avgScore >= 0.6 ? '#d97706' : '#c0392b' }}>
{Math.round(avgScore * 100)}%
</Typography>
<Box sx={{ mt: 1 }}>
<LinearProgress
variant="determinate"
value={avgScore * 100}
color={getQualityColor(avgScore)}
sx={{ height: 8, borderRadius: 4 }}
/>
</Box>
<LinearProgress variant="determinate" value={avgScore * 100} color={getQualityColor(avgScore)}
sx={{ height: 8, borderRadius: 4, mt: 1 }} />
</Card>
{/* Critical Issues */}
<Card sx={{ elevation: 1, p: 2.5 }}>
<Typography variant="overline" color="text.secondary" sx={{ display: 'block' }}>Kritische Felder fehlen</Typography>
<Typography
variant="h4"
sx={{ fontWeight: 700, color: criticalIssues.length > 2 ? '#c0392b' : criticalIssues.length > 0 ? '#d97706' : '#1a7a4a' }}
>
<Card sx={{ p: 2.5 }}>
<Typography variant="overline" color="text.secondary" sx={{ display: 'block' }}>Pflichtfelder fehlen</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: criticalIssues.length > 2 ? '#c0392b' : criticalIssues.length > 0 ? '#d97706' : '#1a7a4a' }}>
{criticalIssues.length}
</Typography>
<Typography variant="caption" color="text.secondary">
von {properties.length} Objekten
</Typography>
<Typography variant="caption" color="text.secondary">von {properties.length} Objekten</Typography>
</Card>
{/* Stale Data */}
<Card sx={{ elevation: 1, p: 2.5 }}>
<Card sx={{ p: 2.5 }}>
<Typography variant="overline" color="text.secondary" sx={{ display: 'block' }}>Veraltete Daten</Typography>
<Typography
variant="h4"
sx={{ fontWeight: 700, color: staleData.length > 2 ? '#c0392b' : staleData.length > 0 ? '#d97706' : '#1a7a4a' }}
>
<Typography variant="h4" sx={{ fontWeight: 700, color: staleData.length > 2 ? '#c0392b' : staleData.length > 0 ? '#d97706' : '#1a7a4a' }}>
{staleData.length}
</Typography>
<Typography variant="caption" color="text.secondary">
von {properties.length} Objekten
</Typography>
<Typography variant="caption" color="text.secondary">von {properties.length} Objekten</Typography>
</Card>
</Box>
{/* Quality Distribution */}
<SectionContainer title="Qualitätsverteilung">
<Card sx={{ elevation: 1, p: 2.5 }}>
<Card sx={{ p: 2.5 }}>
<Box className="flex flex-col gap-3">
{/* High */}
<Box className="flex items-center gap-3">
<Typography variant="body2" sx={{ width: 120, flexShrink: 0 }}>Hoch (80%)</Typography>
<Chip label={highQuality.length} size="small" color="success" />
<Box className="flex-1">
<LinearProgress
variant="determinate"
value={properties.length > 0 ? (highQuality.length / properties.length) * 100 : 0}
color="success"
sx={{ height: 10, borderRadius: 5 }}
/>
{[
{ label: 'Hoch (≥80%)', count: highQuality.length, color: 'success' as const },
{ label: 'Mittel (6079%)', count: medQuality.length, color: 'warning' as const },
{ label: 'Niedrig (<60%)', count: lowQuality.length, color: 'error' as const },
].map(row => (
<Box key={row.label} className="flex items-center gap-3">
<Typography variant="body2" sx={{ width: 120, flexShrink: 0 }}>{row.label}</Typography>
<Chip label={row.count} size="small" color={row.color} />
<Box className="flex-1">
<LinearProgress
variant="determinate"
value={properties.length > 0 ? (row.count / properties.length) * 100 : 0}
color={row.color}
sx={{ height: 10, borderRadius: 5 }}
/>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ width: 40, textAlign: 'right' }}>
{properties.length > 0 ? Math.round((row.count / properties.length) * 100) : 0}%
</Typography>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ width: 40, textAlign: 'right' }}>
{properties.length > 0 ? Math.round((highQuality.length / properties.length) * 100) : 0}%
</Typography>
</Box>
{/* Medium */}
<Box className="flex items-center gap-3">
<Typography variant="body2" sx={{ width: 120, flexShrink: 0 }}>Mittel (6079%)</Typography>
<Chip label={medQuality.length} size="small" color="warning" />
<Box className="flex-1">
<LinearProgress
variant="determinate"
value={properties.length > 0 ? (medQuality.length / properties.length) * 100 : 0}
color="warning"
sx={{ height: 10, borderRadius: 5 }}
/>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ width: 40, textAlign: 'right' }}>
{properties.length > 0 ? Math.round((medQuality.length / properties.length) * 100) : 0}%
</Typography>
</Box>
{/* Low */}
<Box className="flex items-center gap-3">
<Typography variant="body2" sx={{ width: 120, flexShrink: 0 }}>Niedrig ({'<'}60%)</Typography>
<Chip label={lowQuality.length} size="small" color="error" />
<Box className="flex-1">
<LinearProgress
variant="determinate"
value={properties.length > 0 ? (lowQuality.length / properties.length) * 100 : 0}
color="error"
sx={{ height: 10, borderRadius: 5 }}
/>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ width: 40, textAlign: 'right' }}>
{properties.length > 0 ? Math.round((lowQuality.length / properties.length) * 100) : 0}%
</Typography>
</Box>
))}
</Box>
</Card>
</SectionContainer>
{/* Properties Quality Table */}
<SectionContainer title="Objektübersicht Datenqualität">
<Card sx={{ elevation: 1 }}>
{/* Objects Table */}
<SectionContainer title="Objektübersicht">
{/* Filter bar */}
<Box sx={{ display: 'flex', gap: 1.5, mb: 1.5, alignItems: 'center' }}>
<Select
size="small"
value={qualityFilter}
onChange={e => setQualityFilter(e.target.value as QualityFilter)}
displayEmpty
sx={{ fontSize: '0.8125rem', minWidth: 180 }}
>
<MenuItem value="">Alle Qualitätsstufen</MenuItem>
<MenuItem value="HIGH">Hoch (80%)</MenuItem>
<MenuItem value="MEDIUM">Mittel (6079%)</MenuItem>
<MenuItem value="LOW">Niedrig ({'<'}60%)</MenuItem>
<MenuItem value="INCOMPLETE">Pflichtfelder fehlen</MenuItem>
</Select>
<Typography variant="caption" color="text.secondary">
{filtered.length} von {properties.length} Objekten
</Typography>
</Box>
<Card>
<Table size="small">
<TableHead>
<TableRow sx={{ bgcolor: 'grey.50' }}>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Objekt</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Quelle</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Score</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Kritische Felder</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Optionale Felder</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Qualität</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Aktualität</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Warnungen</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Pflichtfelder</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Nächste Massnahme</Typography></TableCell>
</TableRow>
</TableHead>
<TableBody>
{sortedProperties.map(property => {
const hasCritical = property.dataQuality.missingCriticalFields.length > 0
const missingCritical = property.dataQuality.missingCriticalFields
const missingOptional = property.dataQuality.missingOptionalFields
const score = property.dataQuality.score
{filtered.map(property => {
const q = property.dataQuality
const hasCritical = q.missingCriticalFields.length > 0
const actions = getRecommendedActions(q, q.freshness)
const topAction = actions[0] ?? null
return (
<TableRow
key={property.id}
hover
sx={hasCritical ? { bgcolor: 'rgba(192,57,43,0.04)' } : {}}
sx={hasCritical ? { bgcolor: 'rgba(192,57,43,0.03)' } : {}}
>
{/* Objekt */}
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 500 }}>{property.title}</Typography>
<Typography variant="body2" sx={{ fontWeight: 500, mb: 0.25 }}>{property.title}</Typography>
<Typography variant="caption" color="text.secondary">{property.location.city}</Typography>
</TableCell>
{/* Quelle */}
<TableCell>
<Typography variant="caption" color="text.secondary">
{getResultTypeLabel(property.resultType)}
</Typography>
<DataQualityBadge quality={q} showLabel />
</TableCell>
{/* Score */}
<TableCell>
<Box sx={{ width: 100 }}>
<Box className="flex items-center justify-between mb-1">
<Typography variant="caption" sx={{ fontWeight: 700, color: score >= 0.8 ? '#1a7a4a' : score >= 0.6 ? '#d97706' : '#c0392b' }}>
{Math.round(score * 100)}%
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={score * 100}
color={getQualityColor(score)}
sx={{ height: 5, borderRadius: 2 }}
/>
</Box>
<FreshnessIndicator freshness={q.freshness} lastUpdated={property.sourceUpdatedAt} />
</TableCell>
{/* Kritische Felder */}
<TableCell>
{missingCritical.length === 0 ? (
{q.missingCriticalFields.length === 0 ? (
<Chip label="Vollständig" color="success" size="small" />
) : (
<Box className="flex flex-wrap gap-1 items-center">
{missingCritical.slice(0, 2).map(f => (
<Chip key={f} label={f} color="error" size="small" variant="outlined" />
))}
{missingCritical.length > 2 && (
<Typography variant="caption" sx={{ fontWeight: 600 }} color="error.main">
+{missingCritical.length - 2} weitere
</Typography>
)}
<Tooltip
title={q.missingCriticalFields.join(', ')}
arrow
>
<Chip
label={`${q.missingCriticalFields.length} fehlen`}
color="error"
size="small"
variant="outlined"
/>
</Tooltip>
)}
</TableCell>
<TableCell>
{topAction ? (
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: topAction.priority === 'HIGH' ? '#c0392b' : '#d97706' }}>
{topAction.label}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.65rem' }}>
{topAction.detail}
</Typography>
</Box>
)}
</TableCell>
{/* Optionale Felder */}
<TableCell>
{missingOptional.length === 0 ? (
<Typography variant="caption" color="text.secondary"></Typography>
) : (
<Typography variant="caption" color="text.secondary">
{missingOptional.length} fehlen
</Typography>
)}
</TableCell>
{/* Aktualität */}
<TableCell>
<Chip
label={getFreshnessLabel(property.dataQuality.freshness)}
sx={{ color: getFreshnessColor(property.dataQuality.freshness) }}
size="small"
/>
</TableCell>
{/* Warnungen */}
<TableCell>
{property.dataQuality.warnings.length > 0 ? (
<Alert severity="warning" sx={{ py: 0, px: 1, fontSize: 11 }}>
{property.dataQuality.warnings[0]}
</Alert>
) : (
<Typography variant="caption" color="text.secondary"></Typography>
<Typography variant="caption" color="text.secondary"></Typography>
)}
</TableCell>
</TableRow>