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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user