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>
|
||||
|
||||
+103
-173
@@ -1,28 +1,27 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Card,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
MenuItem,
|
||||
Select,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ErrorState, LoadingPage, SectionContainer } from '../../components/ui'
|
||||
import { DataQualityBadge, FreshnessIndicator } from '../../components/data-quality'
|
||||
import { propertyService } from '../../services/propertyService'
|
||||
import { DataFreshness, ResultType } from '../../domain/enums'
|
||||
import { getRecommendedActions } from '../../services/dataQualityService'
|
||||
import { DataFreshness } 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'
|
||||
}
|
||||
}
|
||||
type QualityFilter = '' | 'HIGH' | 'MEDIUM' | 'LOW' | 'INCOMPLETE'
|
||||
|
||||
function getQualityColor(score: number): 'success' | 'warning' | 'error' {
|
||||
if (score >= 0.8) return 'success'
|
||||
@@ -30,23 +29,9 @@ function getQualityColor(score: number): 'success' | 'warning' | 'error' {
|
||||
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 [qualityFilter, setQualityFilter] = useState<QualityFilter>('')
|
||||
|
||||
const { data: resp, isLoading, error } = useQuery({
|
||||
queryKey: ['properties'],
|
||||
queryFn: () => propertyService.getAll(),
|
||||
@@ -63,234 +48,179 @@ export default function DataQuality() {
|
||||
|
||||
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
|
||||
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)
|
||||
const filtered = [...properties]
|
||||
.filter(p => {
|
||||
if (!qualityFilter) return true
|
||||
if (qualityFilter === 'INCOMPLETE') return p.dataQuality.missingCriticalFields.length > 0
|
||||
if (qualityFilter === 'HIGH') return p.dataQuality.score >= 0.8 && p.dataQuality.missingCriticalFields.length === 0
|
||||
if (qualityFilter === 'MEDIUM') return p.dataQuality.score >= 0.6 && p.dataQuality.score < 0.8
|
||||
if (qualityFilter === 'LOW') return p.dataQuality.score < 0.6
|
||||
return true
|
||||
})
|
||||
.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" sx={{ fontWeight: 700 }} color="text.primary">Datenqualität</Typography>
|
||||
<Typography variant="body2" color="text.secondary">Vollständigkeit und Aktualität der Objektdaten</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }} color="text.primary">Datenpflege</Typography>
|
||||
<Typography variant="body2" color="text.secondary">Vollständigkeit, Aktualität und Vertrauen der Objektdaten</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Content */}
|
||||
<Box sx={{ px: 3, py: 3 }} className="flex flex-col gap-4">
|
||||
|
||||
{/* Summary Stats Row */}
|
||||
{/* Summary Stats */}
|
||||
<Box className="grid grid-cols-3 gap-4">
|
||||
{/* Avg Quality Score */}
|
||||
<Card sx={{ elevation: 1, p: 2.5 }}>
|
||||
<Card sx={{ p: 2.5 }}>
|
||||
<Typography variant="overline" color="text.secondary" sx={{ display: 'block' }}>Ø Qualitätsscore</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, 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>
|
||||
<LinearProgress variant="determinate" value={avgScore * 100} color={getQualityColor(avgScore)}
|
||||
sx={{ height: 8, borderRadius: 4, mt: 1 }} />
|
||||
</Card>
|
||||
|
||||
{/* Critical Issues */}
|
||||
<Card sx={{ elevation: 1, p: 2.5 }}>
|
||||
<Typography variant="overline" color="text.secondary" sx={{ display: 'block' }}>Kritische Felder fehlen</Typography>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{ fontWeight: 700, color: criticalIssues.length > 2 ? '#c0392b' : criticalIssues.length > 0 ? '#d97706' : '#1a7a4a' }}
|
||||
>
|
||||
<Card sx={{ p: 2.5 }}>
|
||||
<Typography variant="overline" color="text.secondary" sx={{ display: 'block' }}>Pflichtfelder fehlen</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: criticalIssues.length > 2 ? '#c0392b' : criticalIssues.length > 0 ? '#d97706' : '#1a7a4a' }}>
|
||||
{criticalIssues.length}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
von {properties.length} Objekten
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">von {properties.length} Objekten</Typography>
|
||||
</Card>
|
||||
|
||||
{/* Stale Data */}
|
||||
<Card sx={{ elevation: 1, p: 2.5 }}>
|
||||
<Card sx={{ p: 2.5 }}>
|
||||
<Typography variant="overline" color="text.secondary" sx={{ display: 'block' }}>Veraltete Daten</Typography>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{ fontWeight: 700, color: staleData.length > 2 ? '#c0392b' : staleData.length > 0 ? '#d97706' : '#1a7a4a' }}
|
||||
>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: staleData.length > 2 ? '#c0392b' : staleData.length > 0 ? '#d97706' : '#1a7a4a' }}>
|
||||
{staleData.length}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
von {properties.length} Objekten
|
||||
</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 }}>
|
||||
<Card sx={{ 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" />
|
||||
{[
|
||||
{ label: 'Hoch (≥80%)', count: highQuality.length, color: 'success' as const },
|
||||
{ label: 'Mittel (60–79%)', count: medQuality.length, color: 'warning' as const },
|
||||
{ label: 'Niedrig (<60%)', count: lowQuality.length, color: 'error' as const },
|
||||
].map(row => (
|
||||
<Box key={row.label} className="flex items-center gap-3">
|
||||
<Typography variant="body2" sx={{ width: 120, flexShrink: 0 }}>{row.label}</Typography>
|
||||
<Chip label={row.count} size="small" color={row.color} />
|
||||
<Box className="flex-1">
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={properties.length > 0 ? (highQuality.length / properties.length) * 100 : 0}
|
||||
color="success"
|
||||
value={properties.length > 0 ? (row.count / properties.length) * 100 : 0}
|
||||
color={row.color}
|
||||
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}%
|
||||
{properties.length > 0 ? Math.round((row.count / properties.length) * 100) : 0}%
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Card>
|
||||
</SectionContainer>
|
||||
|
||||
{/* Properties Quality Table */}
|
||||
<SectionContainer title="Objektübersicht Datenqualität">
|
||||
<Card sx={{ elevation: 1 }}>
|
||||
{/* Objects Table */}
|
||||
<SectionContainer title="Objektübersicht">
|
||||
{/* Filter bar */}
|
||||
<Box sx={{ display: 'flex', gap: 1.5, mb: 1.5, alignItems: 'center' }}>
|
||||
<Select
|
||||
size="small"
|
||||
value={qualityFilter}
|
||||
onChange={e => setQualityFilter(e.target.value as QualityFilter)}
|
||||
displayEmpty
|
||||
sx={{ fontSize: '0.8125rem', minWidth: 180 }}
|
||||
>
|
||||
<MenuItem value="">Alle Qualitätsstufen</MenuItem>
|
||||
<MenuItem value="HIGH">Hoch (≥80%)</MenuItem>
|
||||
<MenuItem value="MEDIUM">Mittel (60–79%)</MenuItem>
|
||||
<MenuItem value="LOW">Niedrig ({'<'}60%)</MenuItem>
|
||||
<MenuItem value="INCOMPLETE">Pflichtfelder fehlen</MenuItem>
|
||||
</Select>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{filtered.length} von {properties.length} Objekten
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Card>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow sx={{ bgcolor: 'grey.50' }}>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Objekt</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Quelle</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Score</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Kritische Felder</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Optionale Felder</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Qualität</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Aktualität</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Warnungen</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Pflichtfelder</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Nächste Massnahme</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
|
||||
{filtered.map(property => {
|
||||
const q = property.dataQuality
|
||||
const hasCritical = q.missingCriticalFields.length > 0
|
||||
const actions = getRecommendedActions(q, q.freshness)
|
||||
const topAction = actions[0] ?? null
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={property.id}
|
||||
hover
|
||||
sx={hasCritical ? { bgcolor: 'rgba(192,57,43,0.04)' } : {}}
|
||||
sx={hasCritical ? { bgcolor: 'rgba(192,57,43,0.03)' } : {}}
|
||||
>
|
||||
{/* Objekt */}
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>{property.title}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, mb: 0.25 }}>{property.title}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{property.location.city}</Typography>
|
||||
</TableCell>
|
||||
|
||||
{/* Quelle */}
|
||||
<TableCell>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{getResultTypeLabel(property.resultType)}
|
||||
</Typography>
|
||||
<DataQualityBadge quality={q} showLabel />
|
||||
</TableCell>
|
||||
|
||||
{/* Score */}
|
||||
<TableCell>
|
||||
<Box sx={{ width: 100 }}>
|
||||
<Box className="flex items-center justify-between mb-1">
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, 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>
|
||||
<FreshnessIndicator freshness={q.freshness} lastUpdated={property.sourceUpdatedAt} />
|
||||
</TableCell>
|
||||
|
||||
{/* Kritische Felder */}
|
||||
<TableCell>
|
||||
{missingCritical.length === 0 ? (
|
||||
{q.missingCriticalFields.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" sx={{ fontWeight: 600 }} color="error.main">
|
||||
+{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>
|
||||
<Tooltip
|
||||
title={q.missingCriticalFields.join(', ')}
|
||||
arrow
|
||||
>
|
||||
<Chip
|
||||
label={getFreshnessLabel(property.dataQuality.freshness)}
|
||||
sx={{ color: getFreshnessColor(property.dataQuality.freshness) }}
|
||||
label={`${q.missingCriticalFields.length} fehlen`}
|
||||
color="error"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* Warnungen */}
|
||||
<TableCell>
|
||||
{property.dataQuality.warnings.length > 0 ? (
|
||||
<Alert severity="warning" sx={{ py: 0, px: 1, fontSize: 11 }}>
|
||||
{property.dataQuality.warnings[0]}
|
||||
</Alert>
|
||||
{topAction ? (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: topAction.priority === 'HIGH' ? '#c0392b' : '#d97706' }}>
|
||||
{topAction.label}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.65rem' }}>
|
||||
{topAction.detail}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="caption" color="text.secondary">–</Typography>
|
||||
<Typography variant="caption" color="text.secondary">—</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -1,5 +1,133 @@
|
||||
import { MockupPropertyProvider } from '../provider/MockupPropertyProvider'
|
||||
import { FreshnessStatus } from '../domain/enums'
|
||||
import type { DataQualitySummary } from '../domain/dashboard'
|
||||
import type { DataQuality, Property } from '../domain/property'
|
||||
|
||||
export type RecommendedAction = {
|
||||
id: string
|
||||
label: string
|
||||
detail: string
|
||||
priority: 'HIGH' | 'MEDIUM' | 'LOW'
|
||||
field?: string
|
||||
}
|
||||
|
||||
// ── Field → Action map ────────────────────────────────────────────────────────
|
||||
|
||||
const FIELD_ACTION_MAP: Record<string, Omit<RecommendedAction, 'id'>> = {
|
||||
'Mietpreis/m²': { label: 'Mietpreis ergänzen', detail: 'Fehlender Mietpreis schließt Objekt aus Budget-Matches aus', priority: 'HIGH', field: 'Mietpreis/m²' },
|
||||
'Fläche m²': { label: 'Fläche bestätigen', detail: 'Fläche ist Hard-Kriterium für alle Matchings', priority: 'HIGH', field: 'Fläche m²' },
|
||||
'Verfügbarkeit': { label: 'Verfügbarkeit bestätigen', detail: 'Timing ist entscheidend für Nachfrager mit Deadlines', priority: 'HIGH', field: 'Verfügbarkeit' },
|
||||
'Adresse': { label: 'Adresse vervollständigen', detail: 'Für Standortbewertung und Kartenansicht notwendig', priority: 'HIGH', field: 'Adresse' },
|
||||
'Beschreibung': { label: 'Beschreibung hinzufügen', detail: 'Verbesserter Kontext erhöht Nachfrager-Vertrauen', priority: 'MEDIUM', field: 'Beschreibung' },
|
||||
'Soft Factors': { label: 'Passantenfrequenz & ESG', detail: 'Soft Factors verbessern Match-Scoring erheblich', priority: 'MEDIUM', field: 'Soft Factors' },
|
||||
'Ausbaustandard': { label: 'Ausbaustandard angeben', detail: 'SHELL/BASIC/FULL/PREMIUM beeinflusst Eignung stark', priority: 'MEDIUM', field: 'Ausbaustandard' },
|
||||
'Bilder': { label: 'Bilder hochladen', detail: 'Objektfotos steigern Anfragerate deutlich', priority: 'MEDIUM', field: 'Bilder' },
|
||||
'Jahresmiete (CHF)': { label: 'Jahresmiete angeben', detail: 'Ergänzt Mietpreis/m² für Budgetvergleiche', priority: 'LOW', field: 'Jahresmiete (CHF)' },
|
||||
'Expansionspotenzial': { label: 'Erweiterungsfläche angeben', detail: 'Wichtig für wachsende Unternehmen', priority: 'LOW', field: 'Expansionspotenzial' },
|
||||
}
|
||||
|
||||
const FRESHNESS_ACTIONS: Record<string, RecommendedAction> = {
|
||||
[FreshnessStatus.OUTDATED]: {
|
||||
id: 'review_source',
|
||||
label: 'Quelle überprüfen',
|
||||
detail: 'Daten sind älter als 14 Tage — Verfügbarkeit könnte sich geändert haben',
|
||||
priority: 'HIGH',
|
||||
},
|
||||
[FreshnessStatus.STALE]: {
|
||||
id: 'update_data',
|
||||
label: 'Daten aktualisieren',
|
||||
detail: 'Daten sind 2–14 Tage alt — Aktualitätsscore reduziert',
|
||||
priority: 'MEDIUM',
|
||||
},
|
||||
}
|
||||
|
||||
// ── Core Checks ───────────────────────────────────────────────────────────────
|
||||
|
||||
const CRITICAL_CHECKS: Array<{ field: string; present: (p: Property) => boolean }> = [
|
||||
{ field: 'Mietpreis/m²', present: p => p.rentPricePerSqm > 0 },
|
||||
{ field: 'Fläche m²', present: p => p.areaSqm > 0 },
|
||||
{ field: 'Verfügbarkeit', present: p => !!p.availabilityDate },
|
||||
{ field: 'Adresse', present: p => !!p.address?.street && !!p.address?.city },
|
||||
]
|
||||
|
||||
const OPTIONAL_CHECKS: Array<{ field: string; present: (p: Property) => boolean }> = [
|
||||
{ field: 'Beschreibung', present: p => !!p.description && p.description.length > 20 },
|
||||
{ field: 'Soft Factors', present: p => !!(p.softFactors?.prestigeScore || p.softFactors?.footfallScore || p.softFactors?.commuterAccessScore) },
|
||||
{ field: 'Ausbaustandard', present: p => !!p.hardFacts?.fitOut },
|
||||
{ field: 'Bilder', present: p => (p.images?.length ?? 0) > 0 },
|
||||
{ field: 'Jahresmiete (CHF)', present: p => !!p.rentChfSqmYear },
|
||||
{ field: 'Expansionspotenzial', present: p => !!(p.expansionPotentialSqm || p.hardFacts) },
|
||||
]
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function getMissingCriticalFields(property: Property): string[] {
|
||||
const fromData = property.dataQuality?.missingCriticalFields ?? []
|
||||
if (fromData.length > 0) return fromData
|
||||
return CRITICAL_CHECKS.filter(c => !c.present(property)).map(c => c.field)
|
||||
}
|
||||
|
||||
export function getQualityWarnings(property: Property): string[] {
|
||||
return property.dataQuality?.warnings ?? []
|
||||
}
|
||||
|
||||
export function getRecommendedActions(quality: DataQuality, freshness?: string): RecommendedAction[] {
|
||||
const actions: RecommendedAction[] = []
|
||||
|
||||
for (const field of quality.missingCriticalFields) {
|
||||
const def = FIELD_ACTION_MAP[field]
|
||||
if (def) actions.push({ id: `fill_${field}`, ...def })
|
||||
}
|
||||
|
||||
const fn = freshness ?? quality.freshness
|
||||
if (fn && fn !== FreshnessStatus.FRESH) {
|
||||
const freshnessAction = FRESHNESS_ACTIONS[fn]
|
||||
if (freshnessAction) actions.push(freshnessAction)
|
||||
}
|
||||
|
||||
for (const field of quality.missingOptionalFields) {
|
||||
const def = FIELD_ACTION_MAP[field]
|
||||
if (def) actions.push({ id: `fill_opt_${field}`, ...def })
|
||||
}
|
||||
|
||||
return actions
|
||||
}
|
||||
|
||||
export function calculatePropertyQuality(property: Property): DataQuality {
|
||||
if (property.dataQuality?.qualityLevel) return property.dataQuality
|
||||
|
||||
const missingCritical = CRITICAL_CHECKS.filter(c => !c.present(property)).map(c => c.field)
|
||||
const missingOptional = OPTIONAL_CHECKS.filter(c => !c.present(property)).map(c => c.field)
|
||||
|
||||
const completeness = 1 - (missingCritical.length * 0.15 + missingOptional.length * 0.05)
|
||||
const confidence = property.confidenceScore ?? 0.5
|
||||
|
||||
const freshnessVal = property.dataQuality?.freshness ?? FreshnessStatus.OUTDATED
|
||||
const freshnessFactor = freshnessVal === FreshnessStatus.FRESH ? 1 : freshnessVal === FreshnessStatus.STALE ? 0.7 : 0.4
|
||||
|
||||
const score = Math.min(1, Math.max(0, completeness * 0.5 + confidence * 0.3 + freshnessFactor * 0.2))
|
||||
|
||||
const warnings: string[] = []
|
||||
if (confidence < 0.5) warnings.push('Niedrige Daten-Vertrauensscore')
|
||||
if (freshnessVal === FreshnessStatus.OUTDATED) warnings.push('Daten sind veraltet (>14 Tage)')
|
||||
if (missingCritical.length > 0) warnings.push(`${missingCritical.length} Pflichtfeld(er) fehlen`)
|
||||
|
||||
const qualityLevel = missingCritical.length > 0
|
||||
? 'INCOMPLETE'
|
||||
: score >= 0.8 ? 'HIGH' : score >= 0.6 ? 'MEDIUM' : 'LOW'
|
||||
|
||||
return {
|
||||
score,
|
||||
qualityLevel,
|
||||
missingCriticalFields: missingCritical,
|
||||
missingOptionalFields: missingOptional,
|
||||
lastVerifiedAt: property.dataQuality?.lastVerifiedAt,
|
||||
freshness: freshnessVal,
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Portfolio summary (existing) ──────────────────────────────────────────────
|
||||
|
||||
export const dataQualityService = {
|
||||
async getPortfolioQualitySummary(): Promise<DataQualitySummary> {
|
||||
@@ -31,4 +159,9 @@ export const dataQualityService = {
|
||||
topMissingFields,
|
||||
}
|
||||
},
|
||||
|
||||
getMissingCriticalFields,
|
||||
getQualityWarnings,
|
||||
getRecommendedActions,
|
||||
calculatePropertyQuality,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user