Files
property-match/src/pages/supply/DataQuality.tsx
T
Benjamin Sutter 1d0cb8b154 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>
2026-05-17 12:51:35 +02:00

238 lines
10 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 { useState } from 'react'
import {
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 { getRecommendedActions } from '../../services/dataQualityService'
import { DataFreshness } from '../../domain/enums'
type QualityFilter = '' | 'HIGH' | 'MEDIUM' | 'LOW' | 'INCOMPLETE'
function getQualityColor(score: number): 'success' | 'warning' | 'error' {
if (score >= 0.8) return 'success'
if (score >= 0.6) return 'warning'
return 'error'
}
export default function DataQuality() {
const [qualityFilter, setQualityFilter] = useState<QualityFilter>('')
const { data: resp, isLoading, error } = useQuery({
queryKey: ['properties'],
queryFn: () => propertyService.getAll(),
})
if (isLoading) return <LoadingPage />
if (error) return <ErrorState />
const properties = resp?.data ?? []
const avgScore = properties.length
? properties.reduce((sum, p) => sum + p.dataQuality.score, 0) / properties.length
: 0
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,
)
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)
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">Datenpflege</Typography>
<Typography variant="body2" color="text.secondary">Vollständigkeit, Aktualität und Vertrauen der Objektdaten</Typography>
</Box>
<Box sx={{ px: 3, py: 3 }} className="flex flex-col gap-4">
{/* Summary Stats */}
<Box className="grid grid-cols-3 gap-4">
<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>
<LinearProgress variant="determinate" value={avgScore * 100} color={getQualityColor(avgScore)}
sx={{ height: 8, borderRadius: 4, mt: 1 }} />
</Card>
<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>
</Card>
<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' }}>
{staleData.length}
</Typography>
<Typography variant="caption" color="text.secondary">von {properties.length} Objekten</Typography>
</Card>
</Box>
{/* Quality Distribution */}
<SectionContainer title="Qualitätsverteilung">
<Card sx={{ p: 2.5 }}>
<Box className="flex flex-col gap-3">
{[
{ 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>
))}
</Box>
</Card>
</SectionContainer>
{/* 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 }}>Qualität</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Aktualität</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>
{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.03)' } : {}}
>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 500, mb: 0.25 }}>{property.title}</Typography>
<Typography variant="caption" color="text.secondary">{property.location.city}</Typography>
</TableCell>
<TableCell>
<DataQualityBadge quality={q} showLabel />
</TableCell>
<TableCell>
<FreshnessIndicator freshness={q.freshness} lastUpdated={property.sourceUpdatedAt} />
</TableCell>
<TableCell>
{q.missingCriticalFields.length === 0 ? (
<Chip label="Vollständig" color="success" size="small" />
) : (
<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>
) : (
<Typography variant="caption" color="text.secondary"></Typography>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</Card>
</SectionContainer>
</Box>
</Box>
)
}