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
+4 -107
View File
@@ -2,7 +2,6 @@ import { useState } from 'react'
import {
Alert,
Box,
Button,
Chip,
Divider,
IconButton,
@@ -11,12 +10,12 @@ import {
Tabs,
Typography,
} from '@mui/material'
import { X, ExternalLink } from 'lucide-react'
import { X } from 'lucide-react'
import { NegotiationInsightsPanel } from './NegotiationInsightsPanel'
import { DataQualityPanel as DQPanel, ProvenancePanel } from '../data-quality'
import type { Property } from '../../domain/property'
import type { Match } from '../../domain/match'
import type { FutureSignal } from '../../domain/futureSignal'
import { FreshnessStatus } from '../../domain/enums'
import { usePropertyById, usePropertyMatches, usePropertySignals } from '../../hooks/useProperties'
import { PropertyDetailSkeleton } from './PropertyDetailSkeleton'
import {
@@ -281,108 +280,6 @@ function MatchabilityPanel({ matches }: { matches: Match[] }) {
)
}
function DataQualityPanel({ p }: { p: Property }) {
return (
<Box>
<SectionTitle title="Datenqualität" />
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" color="text.secondary">Gesamtscore</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{Math.round(p.dataQuality.score * 100)}%</Typography>
</Box>
<LinearProgress
variant="determinate"
value={p.dataQuality.score * 100}
color={qualityColor(p.dataQuality.score)}
sx={{ height: 8, borderRadius: 4, mb: 2 }}
/>
{p.dataQuality.missingCriticalFields.length > 0 && (
<>
<SectionTitle title="Kritische fehlende Felder" />
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mb: 2 }}>
{p.dataQuality.missingCriticalFields.map(f => (
<Chip key={f} label={f} size="small" color="error" variant="outlined" />
))}
</Box>
</>
)}
{p.dataQuality.missingOptionalFields.length > 0 && (
<>
<SectionTitle title="Optionale fehlende Felder" />
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mb: 2 }}>
{p.dataQuality.missingOptionalFields.map(f => (
<Chip key={f} label={f} size="small" color="warning" variant="outlined" />
))}
</Box>
</>
)}
{p.dataQuality.warnings.length > 0 && (
<>
<SectionTitle title="Warnungen" />
{p.dataQuality.warnings.map((w, i) => (
<Alert key={i} severity="warning" sx={{ mb: 1, py: 0 }}>{w}</Alert>
))}
</>
)}
<Divider sx={{ my: 2 }} />
<Button variant="outlined" size="small" sx={{ textTransform: 'none' }}>
Datenaktualisierung anfragen
</Button>
</Box>
)
}
function SourcePanel({ p }: { p: Property }) {
const freshnessColor = p.dataQuality.freshness === FreshnessStatus.FRESH
? 'success'
: p.dataQuality.freshness === FreshnessStatus.STALE
? 'warning'
: 'error'
return (
<Box>
<SectionTitle title="Quellinformationen" />
<FieldGrid>
<Field label="Quelltyp" value={p.sourceType} />
<Field label="Quellenbezeichnung" value={p.sourceLabel} />
<Field label="Letzte Quellenaktualisierung" value={p.sourceUpdatedAt ? new Date(p.sourceUpdatedAt).toLocaleDateString('de-CH') : undefined} />
<Field label="Letzte Verifikation" value={p.dataQuality.lastVerifiedAt ? new Date(p.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH') : undefined} />
</FieldGrid>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
<Typography variant="caption" color="text.secondary">Frische:</Typography>
<Chip label={p.dataQuality.freshness} size="small" color={freshnessColor as 'success' | 'warning' | 'error'} />
</Box>
{p.sourceUrl && (
<Box sx={{ mt: 2 }}>
<Button
size="small"
variant="outlined"
endIcon={<ExternalLink size={13} />}
href={p.sourceUrl}
target="_blank"
rel="noopener noreferrer"
sx={{ textTransform: 'none' }}
>
Originalquelle öffnen
</Button>
</Box>
)}
{p.sourceMeta && (
<>
<Divider sx={{ my: 2 }} />
<SectionTitle title="Provenance-Details" />
<FieldGrid>
<Field label="Externe ID" value={p.sourceMeta.externalId} />
<Field label="Quelle Label" value={p.sourceMeta.sourceLabel} />
</FieldGrid>
</>
)}
</Box>
)
}
function SignalsPanel({ signals }: { signals: FutureSignal[] }) {
if (signals.length === 0) {
@@ -491,8 +388,8 @@ export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewPr
{tab === 2 && <HardFactsPanel p={property} />}
{tab === 3 && <SoftFactorsPanel p={property} />}
{tab === 4 && <MatchabilityPanel matches={matches} />}
{tab === 5 && <DataQualityPanel p={property} />}
{tab === 6 && <SourcePanel p={property} />}
{tab === 5 && <DQPanel property={property} />}
{tab === 6 && <ProvenancePanel property={property} />}
{tab === 7 && <SignalsPanel signals={signals} />}
</Box>
</Box>