1d0cb8b154
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>
238 lines
10 KiB
TypeScript
238 lines
10 KiB
TypeScript
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 (60–79%)', 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 (60–79%)</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>
|
||
)
|
||
}
|