diff --git a/src/components/data-quality/CriticalFieldWarning.tsx b/src/components/data-quality/CriticalFieldWarning.tsx
new file mode 100644
index 0000000..6e3075e
--- /dev/null
+++ b/src/components/data-quality/CriticalFieldWarning.tsx
@@ -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 (
+
+ {fields.length > 0 && (
+
+
+ {fields.length} Pflichtfeld{fields.length > 1 ? 'er' : ''} fehlen — Match-Qualität reduziert
+
+
+ {fields.map(f => (
+
+ ))}
+
+
+ )}
+ {warnings.map((w, i) => (
+
+ {w}
+
+ ))}
+
+ )
+}
diff --git a/src/components/data-quality/DataQualityBadge.tsx b/src/components/data-quality/DataQualityBadge.tsx
new file mode 100644
index 0000000..90c0230
--- /dev/null
+++ b/src/components/data-quality/DataQualityBadge.tsx
@@ -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 (
+
+
+
+ )
+}
diff --git a/src/components/data-quality/DataQualityPanel.tsx b/src/components/data-quality/DataQualityPanel.tsx
new file mode 100644
index 0000000..86e4867
--- /dev/null
+++ b/src/components/data-quality/DataQualityPanel.tsx
@@ -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 (
+
+ {children}
+
+ )
+}
+
+export function DataQualityPanel({ property }: DataQualityPanelProps) {
+ const q = property.dataQuality
+ const actions = getRecommendedActions(q, q.freshness)
+
+ return (
+
+ {/* ── Score & Dimensions ─────────────────────────────────────────── */}
+
+
+
+ Datenqualität
+
+
+
+
+ {q.missingCriticalFields.length === 0 ? 'Alle Pflichtfelder vorhanden' : `${q.missingCriticalFields.length} Pflichtfeld(er) fehlen`}
+
+
+ {q.missingOptionalFields.length} optionale Felder fehlen
+
+
+
+
+
+
+ {/* ── Critical warnings ──────────────────────────────────────────── */}
+
+
+ {/* ── Missing data with actions ──────────────────────────────────── */}
+
+ Fehlende Daten & Massnahmen
+
+
+
+
+
+ {/* ── Provenance ─────────────────────────────────────────────────── */}
+
+ Datenherkunft & Verifikation
+
+
+
+ {/* ── Action button ──────────────────────────────────────────────── */}
+
+
+ {q.missingCriticalFields.length > 0 && (
+
+ )}
+
+
+ )
+}
diff --git a/src/components/data-quality/DataQualityProgress.tsx b/src/components/data-quality/DataQualityProgress.tsx
new file mode 100644
index 0000000..d1a97b4
--- /dev/null
+++ b/src/components/data-quality/DataQualityProgress.tsx
@@ -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 = {
+ 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 (
+
+ {dims.map(d => (
+
+
+
+ ))}
+
+ )
+ }
+
+ return (
+
+ {/* Overall score header */}
+
+
+ {overallPct}%
+
+ Gesamtqualität
+
+
+ {/* Dimension bars */}
+
+ {dims.map(d => (
+
+
+ {d.label}
+ {d.score}%
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/data-quality/FreshnessIndicator.tsx b/src/components/data-quality/FreshnessIndicator.tsx
new file mode 100644
index 0000000..8c0e88d
--- /dev/null
+++ b/src/components/data-quality/FreshnessIndicator.tsx
@@ -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 = {
+ [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 = (
+ }
+ 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 (
+
+ {chip}
+
+ )
+}
diff --git a/src/components/data-quality/MissingDataList.tsx b/src/components/data-quality/MissingDataList.tsx
new file mode 100644
index 0000000..0707133
--- /dev/null
+++ b/src/components/data-quality/MissingDataList.tsx
@@ -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 (
+
+
+
+ Alle wichtigen Felder sind vollständig.
+
+
+ )
+ }
+
+ const criticalActions = recommendedActions.filter(a => a.priority === 'HIGH')
+ const otherActions = recommendedActions.filter(a => a.priority !== 'HIGH')
+
+ return (
+
+ {criticalFields.length > 0 && (
+
+
+
+
+ Pflichtfelder ({criticalFields.length})
+
+
+ {criticalActions.map(a => (
+
+
+ {a.label}
+ {a.detail}
+
+ {onAction && (
+
+ )}
+
+ ))}
+ {criticalFields
+ .filter(f => !criticalActions.find(a => a.field === f))
+ .map(f => (
+
+
+
+ ))
+ }
+
+ )}
+
+ {optionalFields.length > 0 && (
+ <>
+ {criticalFields.length > 0 && }
+
+
+ Optionale Felder ({optionalFields.length})
+
+ {otherActions.map(a => (
+
+
+ {a.label}
+ {a.detail}
+
+ {onAction && (
+
+ )}
+
+ ))}
+ {optionalFields
+ .filter(f => !otherActions.find(a => a.field === f))
+ .map(f => (
+
+ ))
+ }
+
+ >
+ )}
+
+ )
+}
diff --git a/src/components/data-quality/ProvenancePanel.tsx b/src/components/data-quality/ProvenancePanel.tsx
new file mode 100644
index 0000000..a74b896
--- /dev/null
+++ b/src/components/data-quality/ProvenancePanel.tsx
@@ -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 = {
+ 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 = {
+ 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 (
+
+ {/* Source header */}
+
+
+ {sourceLabel}
+ {p.sourceLabel && p.sourceLabel !== sourceLabel && (
+ · {p.sourceLabel}
+ )}
+
+
+
+ {/* Source confidence bar */}
+
+
+ Quell-Vertrauen
+ {confPct}%
+
+
+
+
+ {/* Dates */}
+
+
+ Quellaktualisierung
+
+ {p.sourceUpdatedAt ? new Date(p.sourceUpdatedAt).toLocaleDateString('de-CH') : '—'}
+
+
+
+ Letzte Verifikation
+
+ {p.dataQuality.lastVerifiedAt ? new Date(p.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH') : '—'}
+
+
+
+
+ {/* Freshness */}
+
+ Aktualität:
+
+
+
+ {/* External URL */}
+ {p.sourceUrl && (
+ }
+ href={p.sourceUrl}
+ target="_blank"
+ rel="noopener noreferrer"
+ sx={{ textTransform: 'none', fontSize: '0.75rem' }}
+ >
+ Originalquelle öffnen
+
+ )}
+
+ )
+}
diff --git a/src/components/data-quality/index.ts b/src/components/data-quality/index.ts
index b75e201..72eb331 100644
--- a/src/components/data-quality/index.ts
+++ b/src/components/data-quality/index.ts
@@ -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'
diff --git a/src/components/supply/PropertyDetailView.tsx b/src/components/supply/PropertyDetailView.tsx
index e2af1c8..52e4ee5 100644
--- a/src/components/supply/PropertyDetailView.tsx
+++ b/src/components/supply/PropertyDetailView.tsx
@@ -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 (
-
-
-
- Gesamtscore
- {Math.round(p.dataQuality.score * 100)}%
-
-
-
- {p.dataQuality.missingCriticalFields.length > 0 && (
- <>
-
-
- {p.dataQuality.missingCriticalFields.map(f => (
-
- ))}
-
- >
- )}
-
- {p.dataQuality.missingOptionalFields.length > 0 && (
- <>
-
-
- {p.dataQuality.missingOptionalFields.map(f => (
-
- ))}
-
- >
- )}
-
- {p.dataQuality.warnings.length > 0 && (
- <>
-
- {p.dataQuality.warnings.map((w, i) => (
- {w}
- ))}
- >
- )}
-
-
-
-
- )
-}
-
-function SourcePanel({ p }: { p: Property }) {
- const freshnessColor = p.dataQuality.freshness === FreshnessStatus.FRESH
- ? 'success'
- : p.dataQuality.freshness === FreshnessStatus.STALE
- ? 'warning'
- : 'error'
-
- return (
-
-
-
-
-
-
-
-
-
- Frische:
-
-
- {p.sourceUrl && (
-
- }
- href={p.sourceUrl}
- target="_blank"
- rel="noopener noreferrer"
- sx={{ textTransform: 'none' }}
- >
- Originalquelle öffnen
-
-
- )}
- {p.sourceMeta && (
- <>
-
-
-
-
-
-
- >
- )}
-
- )
-}
function SignalsPanel({ signals }: { signals: FutureSignal[] }) {
if (signals.length === 0) {
@@ -491,8 +388,8 @@ export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewPr
{tab === 2 && }
{tab === 3 && }
{tab === 4 && }
- {tab === 5 && }
- {tab === 6 && }
+ {tab === 5 && }
+ {tab === 6 && }
{tab === 7 && }
diff --git a/src/pages/supply/DataQuality.tsx b/src/pages/supply/DataQuality.tsx
index 3f43a8e..105661d 100644
--- a/src/pages/supply/DataQuality.tsx
+++ b/src/pages/supply/DataQuality.tsx
@@ -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('')
+
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 (
{/* Page Header */}
- Datenqualität
- Vollständigkeit und Aktualität der Objektdaten
+ Datenpflege
+ Vollständigkeit, Aktualität und Vertrauen der Objektdaten
- {/* Content */}
- {/* Summary Stats Row */}
+ {/* Summary Stats */}
- {/* Avg Quality Score */}
-
+
Ø Qualitätsscore
= 0.8 ? '#1a7a4a' : avgScore >= 0.6 ? '#d97706' : '#c0392b' }}>
{Math.round(avgScore * 100)}%
-
-
-
+
- {/* Critical Issues */}
-
- Kritische Felder fehlen
- 2 ? '#c0392b' : criticalIssues.length > 0 ? '#d97706' : '#1a7a4a' }}
- >
+
+ Pflichtfelder fehlen
+ 2 ? '#c0392b' : criticalIssues.length > 0 ? '#d97706' : '#1a7a4a' }}>
{criticalIssues.length}
-
- von {properties.length} Objekten
-
+ von {properties.length} Objekten
- {/* Stale Data */}
-
+
Veraltete Daten
- 2 ? '#c0392b' : staleData.length > 0 ? '#d97706' : '#1a7a4a' }}
- >
+ 2 ? '#c0392b' : staleData.length > 0 ? '#d97706' : '#1a7a4a' }}>
{staleData.length}
-
- von {properties.length} Objekten
-
+ von {properties.length} Objekten
{/* Quality Distribution */}
-
+
- {/* High */}
-
- Hoch (≥80%)
-
-
- 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 (60–79%)', count: medQuality.length, color: 'warning' as const },
+ { label: 'Niedrig (<60%)', count: lowQuality.length, color: 'error' as const },
+ ].map(row => (
+
+ {row.label}
+
+
+ 0 ? (row.count / properties.length) * 100 : 0}
+ color={row.color}
+ sx={{ height: 10, borderRadius: 5 }}
+ />
+
+
+ {properties.length > 0 ? Math.round((row.count / properties.length) * 100) : 0}%
+
-
- {properties.length > 0 ? Math.round((highQuality.length / properties.length) * 100) : 0}%
-
-
-
- {/* Medium */}
-
- Mittel (60–79%)
-
-
- 0 ? (medQuality.length / properties.length) * 100 : 0}
- color="warning"
- sx={{ height: 10, borderRadius: 5 }}
- />
-
-
- {properties.length > 0 ? Math.round((medQuality.length / properties.length) * 100) : 0}%
-
-
-
- {/* Low */}
-
- Niedrig ({'<'}60%)
-
-
- 0 ? (lowQuality.length / properties.length) * 100 : 0}
- color="error"
- sx={{ height: 10, borderRadius: 5 }}
- />
-
-
- {properties.length > 0 ? Math.round((lowQuality.length / properties.length) * 100) : 0}%
-
-
+ ))}
- {/* Properties Quality Table */}
-
-
+ {/* Objects Table */}
+
+ {/* Filter bar */}
+
+
+
+ {filtered.length} von {properties.length} Objekten
+
+
+
+
Objekt
- Quelle
- Score
- Kritische Felder
- Optionale Felder
+ Qualität
Aktualität
- Warnungen
+ Pflichtfelder
+ Nächste Massnahme
- {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 (
- {/* Objekt */}
- {property.title}
+ {property.title}
+ {property.location.city}
- {/* Quelle */}
-
- {getResultTypeLabel(property.resultType)}
-
+
- {/* Score */}
-
-
- = 0.8 ? '#1a7a4a' : score >= 0.6 ? '#d97706' : '#c0392b' }}>
- {Math.round(score * 100)}%
-
-
-
-
+
- {/* Kritische Felder */}
- {missingCritical.length === 0 ? (
+ {q.missingCriticalFields.length === 0 ? (
) : (
-
- {missingCritical.slice(0, 2).map(f => (
-
- ))}
- {missingCritical.length > 2 && (
-
- +{missingCritical.length - 2} weitere
-
- )}
+
+
+
+ )}
+
+
+
+ {topAction ? (
+
+
+ {topAction.label}
+
+
+ {topAction.detail}
+
- )}
-
-
- {/* Optionale Felder */}
-
- {missingOptional.length === 0 ? (
- –
) : (
-
- {missingOptional.length} fehlen
-
- )}
-
-
- {/* Aktualität */}
-
-
-
-
- {/* Warnungen */}
-
- {property.dataQuality.warnings.length > 0 ? (
-
- {property.dataQuality.warnings[0]}
-
- ) : (
- –
+ —
)}
diff --git a/src/services/dataQualityService.ts b/src/services/dataQualityService.ts
index b135c7c..dcacbb8 100644
--- a/src/services/dataQualityService.ts
+++ b/src/services/dataQualityService.ts
@@ -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> = {
+ '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 = {
+ [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 {
@@ -31,4 +159,9 @@ export const dataQualityService = {
topMissingFields,
}
},
+
+ getMissingCriticalFields,
+ getQualityWarnings,
+ getRecommendedActions,
+ calculatePropertyQuality,
}