From 1d0cb8b154cc8f3c5b787fd9b75a8fbeca7d9fcd Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Sun, 17 May 2026 12:51:35 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20F017=20data=20quality=20system=20?= =?UTF-8?q?=E2=80=94=20trust=20layer=20across=20all=20decision=20surfaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../data-quality/CriticalFieldWarning.tsx | 33 ++ .../data-quality/DataQualityBadge.tsx | 40 +++ .../data-quality/DataQualityPanel.tsx | 91 ++++++ .../data-quality/DataQualityProgress.tsx | 122 +++++++ .../data-quality/FreshnessIndicator.tsx | 46 +++ .../data-quality/MissingDataList.tsx | 128 ++++++++ .../data-quality/ProvenancePanel.tsx | 126 ++++++++ src/components/data-quality/index.ts | 7 + src/components/supply/PropertyDetailView.tsx | 111 +------ src/pages/supply/DataQuality.tsx | 302 +++++++----------- src/services/dataQualityService.ts | 133 ++++++++ 11 files changed, 846 insertions(+), 293 deletions(-) create mode 100644 src/components/data-quality/CriticalFieldWarning.tsx create mode 100644 src/components/data-quality/DataQualityBadge.tsx create mode 100644 src/components/data-quality/DataQualityPanel.tsx create mode 100644 src/components/data-quality/DataQualityProgress.tsx create mode 100644 src/components/data-quality/FreshnessIndicator.tsx create mode 100644 src/components/data-quality/MissingDataList.tsx create mode 100644 src/components/data-quality/ProvenancePanel.tsx 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 && ( + + )} + + ) +} 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 && ( - - - - )} - {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, }