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
@@ -0,0 +1,33 @@
import { Alert, Box, Chip, Typography } from '@mui/material'
interface CriticalFieldWarningProps {
fields: string[]
warnings?: string[]
}
export function CriticalFieldWarning({ fields, warnings = [] }: CriticalFieldWarningProps) {
if (fields.length === 0 && warnings.length === 0) return null
return (
<Box sx={{ mb: 2 }}>
{fields.length > 0 && (
<Alert severity="error" sx={{ mb: 1, py: 0.5, px: 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
{fields.length} Pflichtfeld{fields.length > 1 ? 'er' : ''} fehlen Match-Qualität reduziert
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{fields.map(f => (
<Chip key={f} label={f} size="small" color="error" variant="outlined"
sx={{ height: 18, fontSize: '0.65rem' }} />
))}
</Box>
</Alert>
)}
{warnings.map((w, i) => (
<Alert key={i} severity="warning" sx={{ mb: 0.5, py: 0.25, px: 1.5, fontSize: '0.8125rem' }}>
{w}
</Alert>
))}
</Box>
)
}
@@ -0,0 +1,40 @@
import { Chip, Tooltip } from '@mui/material'
import { dataQualityHex } from '../../lib/utils'
import type { DataQuality } from '../../domain/property'
interface DataQualityBadgeProps {
quality: DataQuality
showLabel?: boolean
size?: 'small' | 'medium'
}
export function DataQualityBadge({ quality, showLabel = false, size = 'small' }: DataQualityBadgeProps) {
const pct = Math.round(quality.score * 100)
const hex = dataQualityHex(quality.score)
const hasCritical = quality.missingCriticalFields.length > 0
const levelLabel = quality.qualityLevel
? { HIGH: 'Hoch', MEDIUM: 'Mittel', LOW: 'Niedrig', INCOMPLETE: 'Unvollständig' }[quality.qualityLevel]
: null
const tooltipText = hasCritical
? `${quality.missingCriticalFields.length} Pflichtfeld(er) fehlen`
: `Datenqualität: ${pct}%`
return (
<Tooltip title={tooltipText} arrow>
<Chip
size={size}
label={showLabel && levelLabel ? `${pct}% · ${levelLabel}` : `${pct}%`}
sx={{
bgcolor: `${hex}18`,
color: hex,
fontWeight: 700,
fontSize: size === 'small' ? '0.7rem' : '0.8125rem',
border: `1px solid ${hex}40`,
cursor: 'default',
}}
/>
</Tooltip>
)
}
@@ -0,0 +1,91 @@
import { Box, Button, Divider, Paper, Typography } from '@mui/material'
import { DataQualityProgress } from './DataQualityProgress'
import { CriticalFieldWarning } from './CriticalFieldWarning'
import { MissingDataList } from './MissingDataList'
import { ProvenancePanel } from './ProvenancePanel'
import { DataQualityBadge } from './DataQualityBadge'
import { getRecommendedActions } from '../../services/dataQualityService'
import type { Property } from '../../domain/property'
interface DataQualityPanelProps {
property: Property
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<Typography variant="caption" sx={{
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: 0.5,
color: '#64748b',
display: 'block',
mb: 1,
}}>
{children}
</Typography>
)
}
export function DataQualityPanel({ property }: DataQualityPanelProps) {
const q = property.dataQuality
const actions = getRecommendedActions(q, q.freshness)
return (
<Box>
{/* ── Score & Dimensions ─────────────────────────────────────────── */}
<Paper variant="outlined" sx={{ p: 2, mb: 2, bgcolor: '#fafafa' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 1.5 }}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>Datenqualität</Typography>
<DataQualityBadge quality={q} showLabel size="medium" />
</Box>
<Box sx={{ textAlign: 'right' }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{q.missingCriticalFields.length === 0 ? 'Alle Pflichtfelder vorhanden' : `${q.missingCriticalFields.length} Pflichtfeld(er) fehlen`}
</Typography>
<Typography variant="caption" color="text.secondary">
{q.missingOptionalFields.length} optionale Felder fehlen
</Typography>
</Box>
</Box>
<DataQualityProgress property={property} />
</Paper>
{/* ── Critical warnings ──────────────────────────────────────────── */}
<CriticalFieldWarning
fields={q.missingCriticalFields}
warnings={q.warnings}
/>
{/* ── Missing data with actions ──────────────────────────────────── */}
<Box sx={{ mb: 2 }}>
<SectionLabel>Fehlende Daten & Massnahmen</SectionLabel>
<MissingDataList
criticalFields={q.missingCriticalFields}
optionalFields={q.missingOptionalFields}
recommendedActions={actions}
/>
</Box>
<Divider sx={{ mb: 2 }} />
{/* ── Provenance ─────────────────────────────────────────────────── */}
<Box sx={{ mb: 2 }}>
<SectionLabel>Datenherkunft & Verifikation</SectionLabel>
<ProvenancePanel property={property} />
</Box>
{/* ── Action button ──────────────────────────────────────────────── */}
<Box sx={{ pt: 1 }}>
<Button variant="outlined" size="small" sx={{ textTransform: 'none', mr: 1 }}>
Datenaktualisierung anfragen
</Button>
{q.missingCriticalFields.length > 0 && (
<Button variant="contained" size="small" sx={{ textTransform: 'none', bgcolor: '#1e3a5f' }}>
Fehlende Felder ergänzen
</Button>
)}
</Box>
</Box>
)
}
@@ -0,0 +1,122 @@
import { Box, LinearProgress, Typography } from '@mui/material'
import { dataQualityHex } from '../../lib/utils'
import { FreshnessStatus } from '../../domain/enums'
import type { Property } from '../../domain/property'
interface Dimension {
label: string
score: number
color: string
}
function freshnessScore(f: string): number {
if (f === FreshnessStatus.FRESH) return 100
if (f === FreshnessStatus.STALE) return 50
return 15
}
function completenessScore(missingCritical: number, missingOptional: number): number {
const critPenalty = missingCritical * 15
const optPenalty = missingOptional * 5
return Math.max(0, 100 - critPenalty - optPenalty)
}
const SOURCE_PROVENANCE: Record<string, number> = {
ERP_IMPORT: 95, MANUAL_ENTRY: 90, PARTNER_FEED: 80,
IMMOSCOUT_SCRAPE: 65, HOMEGATE_SCRAPE: 65, NEWHOME_SCRAPE: 60,
MATCHOFFICE_SCRAPE: 60, MAISON_WORK_SCRAPE: 60, AI_SIGNAL: 40, UNKNOWN: 30,
}
function dimColor(score: number): string {
if (score >= 80) return '#1a7a4a'
if (score >= 55) return '#d97706'
return '#c0392b'
}
function buildDimensions(p: Property): Dimension[] {
const compScore = completenessScore(
p.dataQuality.missingCriticalFields.length,
p.dataQuality.missingOptionalFields.length,
)
const freshScore = freshnessScore(p.dataQuality.freshness)
const confScore = Math.round(p.confidenceScore * 100)
const provScore = SOURCE_PROVENANCE[p.sourceType] ?? 50
const lastVerified = p.dataQuality.lastVerifiedAt
const verScore = lastVerified
? Math.max(10, 100 - Math.floor((Date.now() - new Date(lastVerified).getTime()) / (1000 * 60 * 60 * 24)) * 2)
: 10
return [
{ label: 'Vollständigkeit', score: compScore, color: dimColor(compScore) },
{ label: 'Aktualität', score: freshScore, color: dimColor(freshScore) },
{ label: 'Vertrauensscore', score: confScore, color: dimColor(confScore) },
{ label: 'Herkunft', score: Math.min(100, provScore), color: dimColor(provScore) },
{ label: 'Verifikation', score: Math.min(100, verScore), color: dimColor(verScore) },
]
}
interface DataQualityProgressProps {
property: Property
compact?: boolean
}
export function DataQualityProgress({ property, compact = false }: DataQualityProgressProps) {
const dims = buildDimensions(property)
const overallPct = Math.round(property.dataQuality.score * 100)
const hex = dataQualityHex(property.dataQuality.score)
if (compact) {
return (
<Box sx={{ display: 'flex', gap: 0.5 }}>
{dims.map(d => (
<Box key={d.label} sx={{ flex: 1 }}>
<LinearProgress
variant="determinate"
value={d.score}
sx={{
height: 4,
borderRadius: 2,
bgcolor: '#e2e8f0',
'& .MuiLinearProgress-bar': { bgcolor: d.color },
}}
/>
</Box>
))}
</Box>
)
}
return (
<Box>
{/* Overall score header */}
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1, mb: 1.5 }}>
<Typography variant="h4" sx={{ fontWeight: 800, color: hex, lineHeight: 1 }}>
{overallPct}%
</Typography>
<Typography variant="body2" color="text.secondary">Gesamtqualität</Typography>
</Box>
{/* Dimension bars */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{dims.map(d => (
<Box key={d.label}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>{d.label}</Typography>
<Typography variant="caption" sx={{ fontWeight: 600, color: d.color, fontSize: '0.7rem' }}>{d.score}%</Typography>
</Box>
<LinearProgress
variant="determinate"
value={d.score}
sx={{
height: 5,
borderRadius: 3,
bgcolor: '#e2e8f0',
'& .MuiLinearProgress-bar': { bgcolor: d.color },
}}
/>
</Box>
))}
</Box>
</Box>
)
}
@@ -0,0 +1,46 @@
import { Chip, Tooltip } from '@mui/material'
import { CheckCircle, Clock, AlertTriangle } from 'lucide-react'
import { FreshnessStatus } from '../../domain/enums'
import { FRESHNESS_LABELS } from '../../lib/constants'
import type { FreshnessStatus as FreshnessStatusType } from '../../domain/enums'
interface FreshnessIndicatorProps {
freshness: FreshnessStatusType
lastUpdated?: string
size?: 'small' | 'medium'
}
const CONFIG: Record<FreshnessStatusType, { color: string; icon: typeof CheckCircle }> = {
[FreshnessStatus.FRESH]: { color: '#1a7a4a', icon: CheckCircle },
[FreshnessStatus.STALE]: { color: '#d97706', icon: Clock },
[FreshnessStatus.OUTDATED]: { color: '#c0392b', icon: AlertTriangle },
}
export function FreshnessIndicator({ freshness, lastUpdated, size = 'small' }: FreshnessIndicatorProps) {
const { color, icon: Icon } = CONFIG[freshness] ?? CONFIG[FreshnessStatus.OUTDATED]
const label = FRESHNESS_LABELS[freshness] ?? freshness
const chip = (
<Chip
size={size}
icon={<Icon size={11} color={color} />}
label={label}
sx={{
bgcolor: `${color}18`,
color,
fontWeight: 600,
fontSize: size === 'small' ? '0.7rem' : '0.8125rem',
border: `1px solid ${color}40`,
'& .MuiChip-icon': { color },
}}
/>
)
if (!lastUpdated) return chip
return (
<Tooltip title={`Zuletzt aktualisiert: ${new Date(lastUpdated).toLocaleDateString('de-CH')}`} arrow>
{chip}
</Tooltip>
)
}
@@ -0,0 +1,128 @@
import { Box, Button, Chip, Divider, Typography } from '@mui/material'
import { AlertTriangle, Info } from 'lucide-react'
import type { RecommendedAction } from '../../services/dataQualityService'
interface MissingDataListProps {
criticalFields: string[]
optionalFields: string[]
recommendedActions: RecommendedAction[]
onAction?: (action: RecommendedAction) => void
}
export function MissingDataList({
criticalFields,
optionalFields,
recommendedActions,
onAction,
}: MissingDataListProps) {
if (criticalFields.length === 0 && optionalFields.length === 0) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 1.5, px: 2, bgcolor: '#f0fdf4', borderRadius: 1, mb: 2 }}>
<Info size={14} color="#1a7a4a" />
<Typography variant="body2" sx={{ color: '#1a7a4a', fontWeight: 500 }}>
Alle wichtigen Felder sind vollständig.
</Typography>
</Box>
)
}
const criticalActions = recommendedActions.filter(a => a.priority === 'HIGH')
const otherActions = recommendedActions.filter(a => a.priority !== 'HIGH')
return (
<Box sx={{ mb: 2 }}>
{criticalFields.length > 0 && (
<Box sx={{ mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
<AlertTriangle size={13} color="#c0392b" />
<Typography variant="caption" sx={{ fontWeight: 700, color: '#c0392b', textTransform: 'uppercase', letterSpacing: 0.5 }}>
Pflichtfelder ({criticalFields.length})
</Typography>
</Box>
{criticalActions.map(a => (
<Box
key={a.id}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 1.5,
py: 0.75,
mb: 0.5,
bgcolor: '#fff1f2',
border: '1px solid #fecdd3',
borderRadius: 1,
}}
>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.8125rem' }}>{a.label}</Typography>
<Typography variant="caption" color="text.secondary">{a.detail}</Typography>
</Box>
{onAction && (
<Button size="small" variant="outlined" color="error"
onClick={() => onAction(a)}
sx={{ textTransform: 'none', fontSize: '0.75rem', flexShrink: 0, ml: 1 }}>
Ergänzen
</Button>
)}
</Box>
))}
{criticalFields
.filter(f => !criticalActions.find(a => a.field === f))
.map(f => (
<Box key={f} sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
<Chip label={f} size="small" color="error" variant="outlined" sx={{ height: 20, fontSize: '0.7rem' }} />
</Box>
))
}
</Box>
)}
{optionalFields.length > 0 && (
<>
{criticalFields.length > 0 && <Divider sx={{ mb: 1.5 }} />}
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}>
Optionale Felder ({optionalFields.length})
</Typography>
{otherActions.map(a => (
<Box
key={a.id}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 1.5,
py: 0.75,
mb: 0.5,
bgcolor: '#fffbeb',
border: '1px solid #fde68a',
borderRadius: 1,
}}
>
<Box>
<Typography variant="body2" sx={{ fontWeight: 500, fontSize: '0.8125rem' }}>{a.label}</Typography>
<Typography variant="caption" color="text.secondary">{a.detail}</Typography>
</Box>
{onAction && (
<Button size="small" variant="outlined" color="warning"
onClick={() => onAction(a)}
sx={{ textTransform: 'none', fontSize: '0.75rem', flexShrink: 0, ml: 1 }}>
Ergänzen
</Button>
)}
</Box>
))}
{optionalFields
.filter(f => !otherActions.find(a => a.field === f))
.map(f => (
<Chip key={f} label={f} size="small" color="warning" variant="outlined"
sx={{ height: 20, fontSize: '0.7rem', mr: 0.5, mb: 0.5 }} />
))
}
</Box>
</>
)}
</Box>
)
}
@@ -0,0 +1,126 @@
import { Box, Button, Chip, LinearProgress, Typography } from '@mui/material'
import { ExternalLink, Shield, ShieldAlert } from 'lucide-react'
import { FreshnessIndicator } from './FreshnessIndicator'
import type { Property } from '../../domain/property'
interface ProvenancePanelProps {
property: Property
}
const SOURCE_TYPE_LABELS: Record<string, string> = {
ERP_IMPORT: 'ERP-Import',
MANUAL_ENTRY: 'Manuelle Eingabe',
IMMOSCOUT_SCRAPE: 'ImmoScout24',
HOMEGATE_SCRAPE: 'Homegate',
NEWHOME_SCRAPE: 'Newhome',
MATCHOFFICE_SCRAPE: 'MatchOffice',
MAISON_WORK_SCRAPE: 'Maison & Work',
AI_SIGNAL: 'KI-Signal',
PARTNER_FEED: 'Partner-Feed',
UNKNOWN: 'Unbekannt',
}
const SOURCE_CONFIDENCE: Record<string, number> = {
ERP_IMPORT: 0.95,
MANUAL_ENTRY: 0.90,
PARTNER_FEED: 0.80,
IMMOSCOUT_SCRAPE: 0.65,
HOMEGATE_SCRAPE: 0.65,
NEWHOME_SCRAPE: 0.60,
MATCHOFFICE_SCRAPE: 0.60,
MAISON_WORK_SCRAPE: 0.60,
AI_SIGNAL: 0.40,
UNKNOWN: 0.30,
}
function getSourceConfidence(sourceType: string): number {
return SOURCE_CONFIDENCE[sourceType] ?? 0.50
}
function provenanceColor(conf: number): string {
if (conf >= 0.8) return '#1a7a4a'
if (conf >= 0.6) return '#d97706'
return '#c0392b'
}
export function ProvenancePanel({ property: p }: ProvenancePanelProps) {
const sourceLabel = SOURCE_TYPE_LABELS[p.sourceType] ?? p.sourceType
const sourceConf = getSourceConfidence(p.sourceType)
const confPct = Math.round(sourceConf * 100)
const color = provenanceColor(sourceConf)
const isVerified = sourceConf >= 0.85
const VerifyIcon = isVerified ? Shield : ShieldAlert
return (
<Box>
{/* Source header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<VerifyIcon size={16} color={color} />
<Typography variant="body2" sx={{ fontWeight: 600 }}>{sourceLabel}</Typography>
{p.sourceLabel && p.sourceLabel !== sourceLabel && (
<Typography variant="caption" color="text.secondary">· {p.sourceLabel}</Typography>
)}
<Chip
size="small"
label={isVerified ? 'Verifiziert' : 'Ungeprüft'}
sx={{ bgcolor: `${color}18`, color, fontSize: '0.65rem', height: 18, ml: 'auto' }}
/>
</Box>
{/* Source confidence bar */}
<Box sx={{ mb: 1.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="caption" color="text.secondary">Quell-Vertrauen</Typography>
<Typography variant="caption" sx={{ fontWeight: 600, color }}>{confPct}%</Typography>
</Box>
<LinearProgress
variant="determinate"
value={confPct}
sx={{
height: 5,
borderRadius: 3,
bgcolor: '#e2e8f0',
'& .MuiLinearProgress-bar': { bgcolor: color },
}}
/>
</Box>
{/* Dates */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5, mb: 1.5 }}>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>Quellaktualisierung</Typography>
<Typography variant="body2" sx={{ fontWeight: 500, fontSize: '0.8125rem' }}>
{p.sourceUpdatedAt ? new Date(p.sourceUpdatedAt).toLocaleDateString('de-CH') : '—'}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>Letzte Verifikation</Typography>
<Typography variant="body2" sx={{ fontWeight: 500, fontSize: '0.8125rem' }}>
{p.dataQuality.lastVerifiedAt ? new Date(p.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH') : '—'}
</Typography>
</Box>
</Box>
{/* Freshness */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Typography variant="caption" color="text.secondary">Aktualität:</Typography>
<FreshnessIndicator freshness={p.dataQuality.freshness} lastUpdated={p.sourceUpdatedAt} />
</Box>
{/* External URL */}
{p.sourceUrl && (
<Button
size="small"
variant="outlined"
endIcon={<ExternalLink size={12} />}
href={p.sourceUrl}
target="_blank"
rel="noopener noreferrer"
sx={{ textTransform: 'none', fontSize: '0.75rem' }}
>
Originalquelle öffnen
</Button>
)}
</Box>
)
}
+7
View File
@@ -1 +1,8 @@
export { DataQualityBar } from './DataQualityBar'
export { DataQualityBadge } from './DataQualityBadge'
export { DataQualityPanel } from './DataQualityPanel'
export { DataQualityProgress } from './DataQualityProgress'
export { FreshnessIndicator } from './FreshnessIndicator'
export { CriticalFieldWarning } from './CriticalFieldWarning'
export { MissingDataList } from './MissingDataList'
export { ProvenancePanel } from './ProvenancePanel'