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