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