Initial commit
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Card,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ErrorState, LoadingPage, SectionContainer } from '../../components/ui'
|
||||
import { propertyService } from '../../services/propertyService'
|
||||
import { DataFreshness, ResultType } 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'
|
||||
}
|
||||
}
|
||||
|
||||
function getQualityColor(score: number): 'success' | 'warning' | 'error' {
|
||||
if (score >= 0.8) return 'success'
|
||||
if (score >= 0.6) return 'warning'
|
||||
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 { 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)
|
||||
|
||||
// Sort by score ascending (worst first)
|
||||
const sortedProperties = [...properties].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" fontWeight={700} color="text.primary">Datenqualität</Typography>
|
||||
<Typography variant="body2" color="text.secondary">Vollständigkeit und Aktualität der Objektdaten</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Content */}
|
||||
<Box sx={{ px: 3, py: 3 }} className="flex flex-col gap-4">
|
||||
|
||||
{/* Summary Stats Row */}
|
||||
<Box className="grid grid-cols-3 gap-4">
|
||||
{/* Avg Quality Score */}
|
||||
<Card sx={{ elevation: 1, p: 2.5 }}>
|
||||
<Typography variant="overline" color="text.secondary" display="block">Ø Qualitätsscore</Typography>
|
||||
<Typography variant="h4" fontWeight={700} sx={{ 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>
|
||||
</Card>
|
||||
|
||||
{/* Critical Issues */}
|
||||
<Card sx={{ elevation: 1, p: 2.5 }}>
|
||||
<Typography variant="overline" color="text.secondary" display="block">Kritische Felder fehlen</Typography>
|
||||
<Typography
|
||||
variant="h4"
|
||||
fontWeight={700}
|
||||
sx={{ 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>
|
||||
|
||||
{/* Stale Data */}
|
||||
<Card sx={{ elevation: 1, p: 2.5 }}>
|
||||
<Typography variant="overline" color="text.secondary" display="block">Veraltete Daten</Typography>
|
||||
<Typography
|
||||
variant="h4"
|
||||
fontWeight={700}
|
||||
sx={{ 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={{ elevation: 1, 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 }}
|
||||
/>
|
||||
</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 (60–79%)</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 }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow sx={{ bgcolor: 'grey.50' }}>
|
||||
<TableCell><Typography variant="caption" fontWeight={600}>Objekt</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" fontWeight={600}>Quelle</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" fontWeight={600}>Score</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" fontWeight={600}>Kritische Felder</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" fontWeight={600}>Optionale Felder</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" fontWeight={600}>Aktualität</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" fontWeight={600}>Warnungen</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
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={property.id}
|
||||
hover
|
||||
sx={hasCritical ? { bgcolor: 'rgba(192,57,43,0.04)' } : {}}
|
||||
>
|
||||
{/* Objekt */}
|
||||
<TableCell>
|
||||
<Typography variant="body2" fontWeight={500}>{property.title}</Typography>
|
||||
</TableCell>
|
||||
|
||||
{/* Quelle */}
|
||||
<TableCell>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{getResultTypeLabel(property.resultType)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
|
||||
{/* Score */}
|
||||
<TableCell>
|
||||
<Box sx={{ width: 100 }}>
|
||||
<Box className="flex items-center justify-between mb-1">
|
||||
<Typography variant="caption" fontWeight={700} sx={{ 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>
|
||||
</TableCell>
|
||||
|
||||
{/* Kritische Felder */}
|
||||
<TableCell>
|
||||
{missingCritical.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" color="error.main" fontWeight={600}>
|
||||
+{missingCritical.length - 2} weitere
|
||||
</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)}
|
||||
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>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</SectionContainer>
|
||||
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user