feat: F017 data quality system — trust layer across all decision surfaces
New components: DataQualityBadge, DataQualityPanel, DataQualityProgress, FreshnessIndicator, CriticalFieldWarning, MissingDataList, ProvenancePanel Service: calculatePropertyQuality, getMissingCriticalFields, getQualityWarnings, getRecommendedActions with field→action mapping PropertyDetailView: tab 5/6 now use DataQualityPanel + ProvenancePanel Datenpflege page: DataQualityBadge, FreshnessIndicator, filter by level, recommended action column showing highest-priority next step per property Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
import { Alert, Box, Chip, Typography } from '@mui/material'
|
||||||
|
|
||||||
|
interface CriticalFieldWarningProps {
|
||||||
|
fields: string[]
|
||||||
|
warnings?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CriticalFieldWarning({ fields, warnings = [] }: CriticalFieldWarningProps) {
|
||||||
|
if (fields.length === 0 && warnings.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
{fields.length > 0 && (
|
||||||
|
<Alert severity="error" sx={{ mb: 1, py: 0.5, px: 1.5 }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
|
||||||
|
{fields.length} Pflichtfeld{fields.length > 1 ? 'er' : ''} fehlen — Match-Qualität reduziert
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||||
|
{fields.map(f => (
|
||||||
|
<Chip key={f} label={f} size="small" color="error" variant="outlined"
|
||||||
|
sx={{ height: 18, fontSize: '0.65rem' }} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{warnings.map((w, i) => (
|
||||||
|
<Alert key={i} severity="warning" sx={{ mb: 0.5, py: 0.25, px: 1.5, fontSize: '0.8125rem' }}>
|
||||||
|
{w}
|
||||||
|
</Alert>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Chip, Tooltip } from '@mui/material'
|
||||||
|
import { dataQualityHex } from '../../lib/utils'
|
||||||
|
import type { DataQuality } from '../../domain/property'
|
||||||
|
|
||||||
|
interface DataQualityBadgeProps {
|
||||||
|
quality: DataQuality
|
||||||
|
showLabel?: boolean
|
||||||
|
size?: 'small' | 'medium'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataQualityBadge({ quality, showLabel = false, size = 'small' }: DataQualityBadgeProps) {
|
||||||
|
const pct = Math.round(quality.score * 100)
|
||||||
|
const hex = dataQualityHex(quality.score)
|
||||||
|
const hasCritical = quality.missingCriticalFields.length > 0
|
||||||
|
|
||||||
|
const levelLabel = quality.qualityLevel
|
||||||
|
? { HIGH: 'Hoch', MEDIUM: 'Mittel', LOW: 'Niedrig', INCOMPLETE: 'Unvollständig' }[quality.qualityLevel]
|
||||||
|
: null
|
||||||
|
|
||||||
|
const tooltipText = hasCritical
|
||||||
|
? `${quality.missingCriticalFields.length} Pflichtfeld(er) fehlen`
|
||||||
|
: `Datenqualität: ${pct}%`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title={tooltipText} arrow>
|
||||||
|
<Chip
|
||||||
|
size={size}
|
||||||
|
label={showLabel && levelLabel ? `${pct}% · ${levelLabel}` : `${pct}%`}
|
||||||
|
sx={{
|
||||||
|
bgcolor: `${hex}18`,
|
||||||
|
color: hex,
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: size === 'small' ? '0.7rem' : '0.8125rem',
|
||||||
|
border: `1px solid ${hex}40`,
|
||||||
|
cursor: 'default',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { Box, Button, Divider, Paper, Typography } from '@mui/material'
|
||||||
|
import { DataQualityProgress } from './DataQualityProgress'
|
||||||
|
import { CriticalFieldWarning } from './CriticalFieldWarning'
|
||||||
|
import { MissingDataList } from './MissingDataList'
|
||||||
|
import { ProvenancePanel } from './ProvenancePanel'
|
||||||
|
import { DataQualityBadge } from './DataQualityBadge'
|
||||||
|
import { getRecommendedActions } from '../../services/dataQualityService'
|
||||||
|
import type { Property } from '../../domain/property'
|
||||||
|
|
||||||
|
interface DataQualityPanelProps {
|
||||||
|
property: Property
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Typography variant="caption" sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
color: '#64748b',
|
||||||
|
display: 'block',
|
||||||
|
mb: 1,
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</Typography>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataQualityPanel({ property }: DataQualityPanelProps) {
|
||||||
|
const q = property.dataQuality
|
||||||
|
const actions = getRecommendedActions(q, q.freshness)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{/* ── Score & Dimensions ─────────────────────────────────────────── */}
|
||||||
|
<Paper variant="outlined" sx={{ p: 2, mb: 2, bgcolor: '#fafafa' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 1.5 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>Datenqualität</Typography>
|
||||||
|
<DataQualityBadge quality={q} showLabel size="medium" />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ textAlign: 'right' }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||||
|
{q.missingCriticalFields.length === 0 ? 'Alle Pflichtfelder vorhanden' : `${q.missingCriticalFields.length} Pflichtfeld(er) fehlen`}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{q.missingOptionalFields.length} optionale Felder fehlen
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<DataQualityProgress property={property} />
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* ── Critical warnings ──────────────────────────────────────────── */}
|
||||||
|
<CriticalFieldWarning
|
||||||
|
fields={q.missingCriticalFields}
|
||||||
|
warnings={q.warnings}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Missing data with actions ──────────────────────────────────── */}
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<SectionLabel>Fehlende Daten & Massnahmen</SectionLabel>
|
||||||
|
<MissingDataList
|
||||||
|
criticalFields={q.missingCriticalFields}
|
||||||
|
optionalFields={q.missingOptionalFields}
|
||||||
|
recommendedActions={actions}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
{/* ── Provenance ─────────────────────────────────────────────────── */}
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<SectionLabel>Datenherkunft & Verifikation</SectionLabel>
|
||||||
|
<ProvenancePanel property={property} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ── Action button ──────────────────────────────────────────────── */}
|
||||||
|
<Box sx={{ pt: 1 }}>
|
||||||
|
<Button variant="outlined" size="small" sx={{ textTransform: 'none', mr: 1 }}>
|
||||||
|
Datenaktualisierung anfragen
|
||||||
|
</Button>
|
||||||
|
{q.missingCriticalFields.length > 0 && (
|
||||||
|
<Button variant="contained" size="small" sx={{ textTransform: 'none', bgcolor: '#1e3a5f' }}>
|
||||||
|
Fehlende Felder ergänzen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { Box, LinearProgress, Typography } from '@mui/material'
|
||||||
|
import { dataQualityHex } from '../../lib/utils'
|
||||||
|
import { FreshnessStatus } from '../../domain/enums'
|
||||||
|
import type { Property } from '../../domain/property'
|
||||||
|
|
||||||
|
interface Dimension {
|
||||||
|
label: string
|
||||||
|
score: number
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function freshnessScore(f: string): number {
|
||||||
|
if (f === FreshnessStatus.FRESH) return 100
|
||||||
|
if (f === FreshnessStatus.STALE) return 50
|
||||||
|
return 15
|
||||||
|
}
|
||||||
|
|
||||||
|
function completenessScore(missingCritical: number, missingOptional: number): number {
|
||||||
|
const critPenalty = missingCritical * 15
|
||||||
|
const optPenalty = missingOptional * 5
|
||||||
|
return Math.max(0, 100 - critPenalty - optPenalty)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCE_PROVENANCE: Record<string, number> = {
|
||||||
|
ERP_IMPORT: 95, MANUAL_ENTRY: 90, PARTNER_FEED: 80,
|
||||||
|
IMMOSCOUT_SCRAPE: 65, HOMEGATE_SCRAPE: 65, NEWHOME_SCRAPE: 60,
|
||||||
|
MATCHOFFICE_SCRAPE: 60, MAISON_WORK_SCRAPE: 60, AI_SIGNAL: 40, UNKNOWN: 30,
|
||||||
|
}
|
||||||
|
|
||||||
|
function dimColor(score: number): string {
|
||||||
|
if (score >= 80) return '#1a7a4a'
|
||||||
|
if (score >= 55) return '#d97706'
|
||||||
|
return '#c0392b'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDimensions(p: Property): Dimension[] {
|
||||||
|
const compScore = completenessScore(
|
||||||
|
p.dataQuality.missingCriticalFields.length,
|
||||||
|
p.dataQuality.missingOptionalFields.length,
|
||||||
|
)
|
||||||
|
const freshScore = freshnessScore(p.dataQuality.freshness)
|
||||||
|
const confScore = Math.round(p.confidenceScore * 100)
|
||||||
|
const provScore = SOURCE_PROVENANCE[p.sourceType] ?? 50
|
||||||
|
const lastVerified = p.dataQuality.lastVerifiedAt
|
||||||
|
const verScore = lastVerified
|
||||||
|
? Math.max(10, 100 - Math.floor((Date.now() - new Date(lastVerified).getTime()) / (1000 * 60 * 60 * 24)) * 2)
|
||||||
|
: 10
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ label: 'Vollständigkeit', score: compScore, color: dimColor(compScore) },
|
||||||
|
{ label: 'Aktualität', score: freshScore, color: dimColor(freshScore) },
|
||||||
|
{ label: 'Vertrauensscore', score: confScore, color: dimColor(confScore) },
|
||||||
|
{ label: 'Herkunft', score: Math.min(100, provScore), color: dimColor(provScore) },
|
||||||
|
{ label: 'Verifikation', score: Math.min(100, verScore), color: dimColor(verScore) },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataQualityProgressProps {
|
||||||
|
property: Property
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataQualityProgress({ property, compact = false }: DataQualityProgressProps) {
|
||||||
|
const dims = buildDimensions(property)
|
||||||
|
const overallPct = Math.round(property.dataQuality.score * 100)
|
||||||
|
const hex = dataQualityHex(property.dataQuality.score)
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||||
|
{dims.map(d => (
|
||||||
|
<Box key={d.label} sx={{ flex: 1 }}>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={d.score}
|
||||||
|
sx={{
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: '#e2e8f0',
|
||||||
|
'& .MuiLinearProgress-bar': { bgcolor: d.color },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{/* Overall score header */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1, mb: 1.5 }}>
|
||||||
|
<Typography variant="h4" sx={{ fontWeight: 800, color: hex, lineHeight: 1 }}>
|
||||||
|
{overallPct}%
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">Gesamtqualität</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Dimension bars */}
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||||
|
{dims.map(d => (
|
||||||
|
<Box key={d.label}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>{d.label}</Typography>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: d.color, fontSize: '0.7rem' }}>{d.score}%</Typography>
|
||||||
|
</Box>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={d.score}
|
||||||
|
sx={{
|
||||||
|
height: 5,
|
||||||
|
borderRadius: 3,
|
||||||
|
bgcolor: '#e2e8f0',
|
||||||
|
'& .MuiLinearProgress-bar': { bgcolor: d.color },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Chip, Tooltip } from '@mui/material'
|
||||||
|
import { CheckCircle, Clock, AlertTriangle } from 'lucide-react'
|
||||||
|
import { FreshnessStatus } from '../../domain/enums'
|
||||||
|
import { FRESHNESS_LABELS } from '../../lib/constants'
|
||||||
|
import type { FreshnessStatus as FreshnessStatusType } from '../../domain/enums'
|
||||||
|
|
||||||
|
interface FreshnessIndicatorProps {
|
||||||
|
freshness: FreshnessStatusType
|
||||||
|
lastUpdated?: string
|
||||||
|
size?: 'small' | 'medium'
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIG: Record<FreshnessStatusType, { color: string; icon: typeof CheckCircle }> = {
|
||||||
|
[FreshnessStatus.FRESH]: { color: '#1a7a4a', icon: CheckCircle },
|
||||||
|
[FreshnessStatus.STALE]: { color: '#d97706', icon: Clock },
|
||||||
|
[FreshnessStatus.OUTDATED]: { color: '#c0392b', icon: AlertTriangle },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FreshnessIndicator({ freshness, lastUpdated, size = 'small' }: FreshnessIndicatorProps) {
|
||||||
|
const { color, icon: Icon } = CONFIG[freshness] ?? CONFIG[FreshnessStatus.OUTDATED]
|
||||||
|
const label = FRESHNESS_LABELS[freshness] ?? freshness
|
||||||
|
|
||||||
|
const chip = (
|
||||||
|
<Chip
|
||||||
|
size={size}
|
||||||
|
icon={<Icon size={11} color={color} />}
|
||||||
|
label={label}
|
||||||
|
sx={{
|
||||||
|
bgcolor: `${color}18`,
|
||||||
|
color,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: size === 'small' ? '0.7rem' : '0.8125rem',
|
||||||
|
border: `1px solid ${color}40`,
|
||||||
|
'& .MuiChip-icon': { color },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!lastUpdated) return chip
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title={`Zuletzt aktualisiert: ${new Date(lastUpdated).toLocaleDateString('de-CH')}`} arrow>
|
||||||
|
{chip}
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { Box, Button, Chip, Divider, Typography } from '@mui/material'
|
||||||
|
import { AlertTriangle, Info } from 'lucide-react'
|
||||||
|
import type { RecommendedAction } from '../../services/dataQualityService'
|
||||||
|
|
||||||
|
interface MissingDataListProps {
|
||||||
|
criticalFields: string[]
|
||||||
|
optionalFields: string[]
|
||||||
|
recommendedActions: RecommendedAction[]
|
||||||
|
onAction?: (action: RecommendedAction) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MissingDataList({
|
||||||
|
criticalFields,
|
||||||
|
optionalFields,
|
||||||
|
recommendedActions,
|
||||||
|
onAction,
|
||||||
|
}: MissingDataListProps) {
|
||||||
|
if (criticalFields.length === 0 && optionalFields.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 1.5, px: 2, bgcolor: '#f0fdf4', borderRadius: 1, mb: 2 }}>
|
||||||
|
<Info size={14} color="#1a7a4a" />
|
||||||
|
<Typography variant="body2" sx={{ color: '#1a7a4a', fontWeight: 500 }}>
|
||||||
|
Alle wichtigen Felder sind vollständig.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const criticalActions = recommendedActions.filter(a => a.priority === 'HIGH')
|
||||||
|
const otherActions = recommendedActions.filter(a => a.priority !== 'HIGH')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
{criticalFields.length > 0 && (
|
||||||
|
<Box sx={{ mb: 1.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
|
||||||
|
<AlertTriangle size={13} color="#c0392b" />
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: '#c0392b', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
|
Pflichtfelder ({criticalFields.length})
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{criticalActions.map(a => (
|
||||||
|
<Box
|
||||||
|
key={a.id}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
px: 1.5,
|
||||||
|
py: 0.75,
|
||||||
|
mb: 0.5,
|
||||||
|
bgcolor: '#fff1f2',
|
||||||
|
border: '1px solid #fecdd3',
|
||||||
|
borderRadius: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.8125rem' }}>{a.label}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">{a.detail}</Typography>
|
||||||
|
</Box>
|
||||||
|
{onAction && (
|
||||||
|
<Button size="small" variant="outlined" color="error"
|
||||||
|
onClick={() => onAction(a)}
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.75rem', flexShrink: 0, ml: 1 }}>
|
||||||
|
Ergänzen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
{criticalFields
|
||||||
|
.filter(f => !criticalActions.find(a => a.field === f))
|
||||||
|
.map(f => (
|
||||||
|
<Box key={f} sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
|
||||||
|
<Chip label={f} size="small" color="error" variant="outlined" sx={{ height: 20, fontSize: '0.7rem' }} />
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{optionalFields.length > 0 && (
|
||||||
|
<>
|
||||||
|
{criticalFields.length > 0 && <Divider sx={{ mb: 1.5 }} />}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}>
|
||||||
|
Optionale Felder ({optionalFields.length})
|
||||||
|
</Typography>
|
||||||
|
{otherActions.map(a => (
|
||||||
|
<Box
|
||||||
|
key={a.id}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
px: 1.5,
|
||||||
|
py: 0.75,
|
||||||
|
mb: 0.5,
|
||||||
|
bgcolor: '#fffbeb',
|
||||||
|
border: '1px solid #fde68a',
|
||||||
|
borderRadius: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500, fontSize: '0.8125rem' }}>{a.label}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">{a.detail}</Typography>
|
||||||
|
</Box>
|
||||||
|
{onAction && (
|
||||||
|
<Button size="small" variant="outlined" color="warning"
|
||||||
|
onClick={() => onAction(a)}
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.75rem', flexShrink: 0, ml: 1 }}>
|
||||||
|
Ergänzen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
{optionalFields
|
||||||
|
.filter(f => !otherActions.find(a => a.field === f))
|
||||||
|
.map(f => (
|
||||||
|
<Chip key={f} label={f} size="small" color="warning" variant="outlined"
|
||||||
|
sx={{ height: 20, fontSize: '0.7rem', mr: 0.5, mb: 0.5 }} />
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { Box, Button, Chip, LinearProgress, Typography } from '@mui/material'
|
||||||
|
import { ExternalLink, Shield, ShieldAlert } from 'lucide-react'
|
||||||
|
import { FreshnessIndicator } from './FreshnessIndicator'
|
||||||
|
import type { Property } from '../../domain/property'
|
||||||
|
|
||||||
|
interface ProvenancePanelProps {
|
||||||
|
property: Property
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCE_TYPE_LABELS: Record<string, string> = {
|
||||||
|
ERP_IMPORT: 'ERP-Import',
|
||||||
|
MANUAL_ENTRY: 'Manuelle Eingabe',
|
||||||
|
IMMOSCOUT_SCRAPE: 'ImmoScout24',
|
||||||
|
HOMEGATE_SCRAPE: 'Homegate',
|
||||||
|
NEWHOME_SCRAPE: 'Newhome',
|
||||||
|
MATCHOFFICE_SCRAPE: 'MatchOffice',
|
||||||
|
MAISON_WORK_SCRAPE: 'Maison & Work',
|
||||||
|
AI_SIGNAL: 'KI-Signal',
|
||||||
|
PARTNER_FEED: 'Partner-Feed',
|
||||||
|
UNKNOWN: 'Unbekannt',
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCE_CONFIDENCE: Record<string, number> = {
|
||||||
|
ERP_IMPORT: 0.95,
|
||||||
|
MANUAL_ENTRY: 0.90,
|
||||||
|
PARTNER_FEED: 0.80,
|
||||||
|
IMMOSCOUT_SCRAPE: 0.65,
|
||||||
|
HOMEGATE_SCRAPE: 0.65,
|
||||||
|
NEWHOME_SCRAPE: 0.60,
|
||||||
|
MATCHOFFICE_SCRAPE: 0.60,
|
||||||
|
MAISON_WORK_SCRAPE: 0.60,
|
||||||
|
AI_SIGNAL: 0.40,
|
||||||
|
UNKNOWN: 0.30,
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSourceConfidence(sourceType: string): number {
|
||||||
|
return SOURCE_CONFIDENCE[sourceType] ?? 0.50
|
||||||
|
}
|
||||||
|
|
||||||
|
function provenanceColor(conf: number): string {
|
||||||
|
if (conf >= 0.8) return '#1a7a4a'
|
||||||
|
if (conf >= 0.6) return '#d97706'
|
||||||
|
return '#c0392b'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProvenancePanel({ property: p }: ProvenancePanelProps) {
|
||||||
|
const sourceLabel = SOURCE_TYPE_LABELS[p.sourceType] ?? p.sourceType
|
||||||
|
const sourceConf = getSourceConfidence(p.sourceType)
|
||||||
|
const confPct = Math.round(sourceConf * 100)
|
||||||
|
const color = provenanceColor(sourceConf)
|
||||||
|
const isVerified = sourceConf >= 0.85
|
||||||
|
const VerifyIcon = isVerified ? Shield : ShieldAlert
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{/* Source header */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
|
<VerifyIcon size={16} color={color} />
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{sourceLabel}</Typography>
|
||||||
|
{p.sourceLabel && p.sourceLabel !== sourceLabel && (
|
||||||
|
<Typography variant="caption" color="text.secondary">· {p.sourceLabel}</Typography>
|
||||||
|
)}
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={isVerified ? 'Verifiziert' : 'Ungeprüft'}
|
||||||
|
sx={{ bgcolor: `${color}18`, color, fontSize: '0.65rem', height: 18, ml: 'auto' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Source confidence bar */}
|
||||||
|
<Box sx={{ mb: 1.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">Quell-Vertrauen</Typography>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color }}>{confPct}%</Typography>
|
||||||
|
</Box>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={confPct}
|
||||||
|
sx={{
|
||||||
|
height: 5,
|
||||||
|
borderRadius: 3,
|
||||||
|
bgcolor: '#e2e8f0',
|
||||||
|
'& .MuiLinearProgress-bar': { bgcolor: color },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Dates */}
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5, mb: 1.5 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>Quellaktualisierung</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500, fontSize: '0.8125rem' }}>
|
||||||
|
{p.sourceUpdatedAt ? new Date(p.sourceUpdatedAt).toLocaleDateString('de-CH') : '—'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>Letzte Verifikation</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500, fontSize: '0.8125rem' }}>
|
||||||
|
{p.dataQuality.lastVerifiedAt ? new Date(p.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH') : '—'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Freshness */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">Aktualität:</Typography>
|
||||||
|
<FreshnessIndicator freshness={p.dataQuality.freshness} lastUpdated={p.sourceUpdatedAt} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* External URL */}
|
||||||
|
{p.sourceUrl && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
endIcon={<ExternalLink size={12} />}
|
||||||
|
href={p.sourceUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
sx={{ textTransform: 'none', fontSize: '0.75rem' }}
|
||||||
|
>
|
||||||
|
Originalquelle öffnen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1 +1,8 @@
|
|||||||
export { DataQualityBar } from './DataQualityBar'
|
export { DataQualityBar } from './DataQualityBar'
|
||||||
|
export { DataQualityBadge } from './DataQualityBadge'
|
||||||
|
export { DataQualityPanel } from './DataQualityPanel'
|
||||||
|
export { DataQualityProgress } from './DataQualityProgress'
|
||||||
|
export { FreshnessIndicator } from './FreshnessIndicator'
|
||||||
|
export { CriticalFieldWarning } from './CriticalFieldWarning'
|
||||||
|
export { MissingDataList } from './MissingDataList'
|
||||||
|
export { ProvenancePanel } from './ProvenancePanel'
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useState } from 'react'
|
|||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
|
||||||
Chip,
|
Chip,
|
||||||
Divider,
|
Divider,
|
||||||
IconButton,
|
IconButton,
|
||||||
@@ -11,12 +10,12 @@ import {
|
|||||||
Tabs,
|
Tabs,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { X, ExternalLink } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import { NegotiationInsightsPanel } from './NegotiationInsightsPanel'
|
import { NegotiationInsightsPanel } from './NegotiationInsightsPanel'
|
||||||
|
import { DataQualityPanel as DQPanel, ProvenancePanel } from '../data-quality'
|
||||||
import type { Property } from '../../domain/property'
|
import type { Property } from '../../domain/property'
|
||||||
import type { Match } from '../../domain/match'
|
import type { Match } from '../../domain/match'
|
||||||
import type { FutureSignal } from '../../domain/futureSignal'
|
import type { FutureSignal } from '../../domain/futureSignal'
|
||||||
import { FreshnessStatus } from '../../domain/enums'
|
|
||||||
import { usePropertyById, usePropertyMatches, usePropertySignals } from '../../hooks/useProperties'
|
import { usePropertyById, usePropertyMatches, usePropertySignals } from '../../hooks/useProperties'
|
||||||
import { PropertyDetailSkeleton } from './PropertyDetailSkeleton'
|
import { PropertyDetailSkeleton } from './PropertyDetailSkeleton'
|
||||||
import {
|
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[] }) {
|
function SignalsPanel({ signals }: { signals: FutureSignal[] }) {
|
||||||
if (signals.length === 0) {
|
if (signals.length === 0) {
|
||||||
@@ -491,8 +388,8 @@ export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewPr
|
|||||||
{tab === 2 && <HardFactsPanel p={property} />}
|
{tab === 2 && <HardFactsPanel p={property} />}
|
||||||
{tab === 3 && <SoftFactorsPanel p={property} />}
|
{tab === 3 && <SoftFactorsPanel p={property} />}
|
||||||
{tab === 4 && <MatchabilityPanel matches={matches} />}
|
{tab === 4 && <MatchabilityPanel matches={matches} />}
|
||||||
{tab === 5 && <DataQualityPanel p={property} />}
|
{tab === 5 && <DQPanel property={property} />}
|
||||||
{tab === 6 && <SourcePanel p={property} />}
|
{tab === 6 && <ProvenancePanel property={property} />}
|
||||||
{tab === 7 && <SignalsPanel signals={signals} />}
|
{tab === 7 && <SignalsPanel signals={signals} />}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
+116
-186
@@ -1,28 +1,27 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Alert,
|
|
||||||
Box,
|
Box,
|
||||||
Card,
|
Card,
|
||||||
Chip,
|
Chip,
|
||||||
LinearProgress,
|
LinearProgress,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableRow,
|
TableRow,
|
||||||
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { ErrorState, LoadingPage, SectionContainer } from '../../components/ui'
|
import { ErrorState, LoadingPage, SectionContainer } from '../../components/ui'
|
||||||
|
import { DataQualityBadge, FreshnessIndicator } from '../../components/data-quality'
|
||||||
import { propertyService } from '../../services/propertyService'
|
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 {
|
type QualityFilter = '' | 'HIGH' | 'MEDIUM' | 'LOW' | 'INCOMPLETE'
|
||||||
switch (type) {
|
|
||||||
case ResultType.VERIFIED_PORTFOLIO: return 'Verified Portfolio'
|
|
||||||
case ResultType.EXTERNAL_MARKET: return 'Marktinserat'
|
|
||||||
case ResultType.FUTURE_AVAILABILITY: return 'Zukunftssignal'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getQualityColor(score: number): 'success' | 'warning' | 'error' {
|
function getQualityColor(score: number): 'success' | 'warning' | 'error' {
|
||||||
if (score >= 0.8) return 'success'
|
if (score >= 0.8) return 'success'
|
||||||
@@ -30,23 +29,9 @@ function getQualityColor(score: number): 'success' | 'warning' | 'error' {
|
|||||||
return '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() {
|
export default function DataQuality() {
|
||||||
|
const [qualityFilter, setQualityFilter] = useState<QualityFilter>('')
|
||||||
|
|
||||||
const { data: resp, isLoading, error } = useQuery({
|
const { data: resp, isLoading, error } = useQuery({
|
||||||
queryKey: ['properties'],
|
queryKey: ['properties'],
|
||||||
queryFn: () => propertyService.getAll(),
|
queryFn: () => propertyService.getAll(),
|
||||||
@@ -63,234 +48,179 @@ export default function DataQuality() {
|
|||||||
|
|
||||||
const criticalIssues = properties.filter(p => p.dataQuality.missingCriticalFields.length > 0)
|
const criticalIssues = properties.filter(p => p.dataQuality.missingCriticalFields.length > 0)
|
||||||
const staleData = properties.filter(
|
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 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 medQuality = properties.filter(p => p.dataQuality.score >= 0.6 && p.dataQuality.score < 0.8)
|
||||||
const lowQuality = properties.filter(p => p.dataQuality.score < 0.6)
|
const lowQuality = properties.filter(p => p.dataQuality.score < 0.6)
|
||||||
|
|
||||||
// Sort by score ascending (worst first)
|
const filtered = [...properties]
|
||||||
const sortedProperties = [...properties].sort((a, b) => a.dataQuality.score - b.dataQuality.score)
|
.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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid', borderColor: 'divider', px: 3, py: 2 }}>
|
<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="h5" sx={{ fontWeight: 700 }} color="text.primary">Datenpflege</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">Vollständigkeit und Aktualität der Objektdaten</Typography>
|
<Typography variant="body2" color="text.secondary">Vollständigkeit, Aktualität und Vertrauen der Objektdaten</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<Box sx={{ px: 3, py: 3 }} className="flex flex-col gap-4">
|
<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">
|
<Box className="grid grid-cols-3 gap-4">
|
||||||
{/* Avg Quality Score */}
|
<Card sx={{ p: 2.5 }}>
|
||||||
<Card sx={{ elevation: 1, p: 2.5 }}>
|
|
||||||
<Typography variant="overline" color="text.secondary" sx={{ display: 'block' }}>Ø Qualitätsscore</Typography>
|
<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' }}>
|
<Typography variant="h4" sx={{ fontWeight: 700, color: avgScore >= 0.8 ? '#1a7a4a' : avgScore >= 0.6 ? '#d97706' : '#c0392b' }}>
|
||||||
{Math.round(avgScore * 100)}%
|
{Math.round(avgScore * 100)}%
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ mt: 1 }}>
|
<LinearProgress variant="determinate" value={avgScore * 100} color={getQualityColor(avgScore)}
|
||||||
<LinearProgress
|
sx={{ height: 8, borderRadius: 4, mt: 1 }} />
|
||||||
variant="determinate"
|
|
||||||
value={avgScore * 100}
|
|
||||||
color={getQualityColor(avgScore)}
|
|
||||||
sx={{ height: 8, borderRadius: 4 }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Critical Issues */}
|
<Card sx={{ p: 2.5 }}>
|
||||||
<Card sx={{ elevation: 1, p: 2.5 }}>
|
<Typography variant="overline" color="text.secondary" sx={{ display: 'block' }}>Pflichtfelder fehlen</Typography>
|
||||||
<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' }}>
|
||||||
<Typography
|
|
||||||
variant="h4"
|
|
||||||
sx={{ fontWeight: 700, color: criticalIssues.length > 2 ? '#c0392b' : criticalIssues.length > 0 ? '#d97706' : '#1a7a4a' }}
|
|
||||||
>
|
|
||||||
{criticalIssues.length}
|
{criticalIssues.length}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">von {properties.length} Objekten</Typography>
|
||||||
von {properties.length} Objekten
|
|
||||||
</Typography>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Stale Data */}
|
<Card sx={{ p: 2.5 }}>
|
||||||
<Card sx={{ elevation: 1, p: 2.5 }}>
|
|
||||||
<Typography variant="overline" color="text.secondary" sx={{ display: 'block' }}>Veraltete Daten</Typography>
|
<Typography variant="overline" color="text.secondary" sx={{ display: 'block' }}>Veraltete Daten</Typography>
|
||||||
<Typography
|
<Typography variant="h4" sx={{ fontWeight: 700, color: staleData.length > 2 ? '#c0392b' : staleData.length > 0 ? '#d97706' : '#1a7a4a' }}>
|
||||||
variant="h4"
|
|
||||||
sx={{ fontWeight: 700, color: staleData.length > 2 ? '#c0392b' : staleData.length > 0 ? '#d97706' : '#1a7a4a' }}
|
|
||||||
>
|
|
||||||
{staleData.length}
|
{staleData.length}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">von {properties.length} Objekten</Typography>
|
||||||
von {properties.length} Objekten
|
|
||||||
</Typography>
|
|
||||||
</Card>
|
</Card>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Quality Distribution */}
|
{/* Quality Distribution */}
|
||||||
<SectionContainer title="Qualitätsverteilung">
|
<SectionContainer title="Qualitätsverteilung">
|
||||||
<Card sx={{ elevation: 1, p: 2.5 }}>
|
<Card sx={{ p: 2.5 }}>
|
||||||
<Box className="flex flex-col gap-3">
|
<Box className="flex flex-col gap-3">
|
||||||
{/* High */}
|
{[
|
||||||
<Box className="flex items-center gap-3">
|
{ label: 'Hoch (≥80%)', count: highQuality.length, color: 'success' as const },
|
||||||
<Typography variant="body2" sx={{ width: 120, flexShrink: 0 }}>Hoch (≥80%)</Typography>
|
{ label: 'Mittel (60–79%)', count: medQuality.length, color: 'warning' as const },
|
||||||
<Chip label={highQuality.length} size="small" color="success" />
|
{ label: 'Niedrig (<60%)', count: lowQuality.length, color: 'error' as const },
|
||||||
<Box className="flex-1">
|
].map(row => (
|
||||||
<LinearProgress
|
<Box key={row.label} className="flex items-center gap-3">
|
||||||
variant="determinate"
|
<Typography variant="body2" sx={{ width: 120, flexShrink: 0 }}>{row.label}</Typography>
|
||||||
value={properties.length > 0 ? (highQuality.length / properties.length) * 100 : 0}
|
<Chip label={row.count} size="small" color={row.color} />
|
||||||
color="success"
|
<Box className="flex-1">
|
||||||
sx={{ height: 10, borderRadius: 5 }}
|
<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>
|
</Box>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ width: 40, textAlign: 'right' }}>
|
))}
|
||||||
{properties.length > 0 ? Math.round((highQuality.length / properties.length) * 100) : 0}%
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Medium */}
|
|
||||||
<Box className="flex items-center gap-3">
|
|
||||||
<Typography variant="body2" sx={{ width: 120, flexShrink: 0 }}>Mittel (60–79%)</Typography>
|
|
||||||
<Chip label={medQuality.length} size="small" color="warning" />
|
|
||||||
<Box className="flex-1">
|
|
||||||
<LinearProgress
|
|
||||||
variant="determinate"
|
|
||||||
value={properties.length > 0 ? (medQuality.length / properties.length) * 100 : 0}
|
|
||||||
color="warning"
|
|
||||||
sx={{ height: 10, borderRadius: 5 }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ width: 40, textAlign: 'right' }}>
|
|
||||||
{properties.length > 0 ? Math.round((medQuality.length / properties.length) * 100) : 0}%
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Low */}
|
|
||||||
<Box className="flex items-center gap-3">
|
|
||||||
<Typography variant="body2" sx={{ width: 120, flexShrink: 0 }}>Niedrig ({'<'}60%)</Typography>
|
|
||||||
<Chip label={lowQuality.length} size="small" color="error" />
|
|
||||||
<Box className="flex-1">
|
|
||||||
<LinearProgress
|
|
||||||
variant="determinate"
|
|
||||||
value={properties.length > 0 ? (lowQuality.length / properties.length) * 100 : 0}
|
|
||||||
color="error"
|
|
||||||
sx={{ height: 10, borderRadius: 5 }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ width: 40, textAlign: 'right' }}>
|
|
||||||
{properties.length > 0 ? Math.round((lowQuality.length / properties.length) * 100) : 0}%
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Card>
|
</Card>
|
||||||
</SectionContainer>
|
</SectionContainer>
|
||||||
|
|
||||||
{/* Properties Quality Table */}
|
{/* Objects Table */}
|
||||||
<SectionContainer title="Objektübersicht Datenqualität">
|
<SectionContainer title="Objektübersicht">
|
||||||
<Card sx={{ elevation: 1 }}>
|
{/* Filter bar */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 1.5, mb: 1.5, alignItems: 'center' }}>
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
value={qualityFilter}
|
||||||
|
onChange={e => setQualityFilter(e.target.value as QualityFilter)}
|
||||||
|
displayEmpty
|
||||||
|
sx={{ fontSize: '0.8125rem', minWidth: 180 }}
|
||||||
|
>
|
||||||
|
<MenuItem value="">Alle Qualitätsstufen</MenuItem>
|
||||||
|
<MenuItem value="HIGH">Hoch (≥80%)</MenuItem>
|
||||||
|
<MenuItem value="MEDIUM">Mittel (60–79%)</MenuItem>
|
||||||
|
<MenuItem value="LOW">Niedrig ({'<'}60%)</MenuItem>
|
||||||
|
<MenuItem value="INCOMPLETE">Pflichtfelder fehlen</MenuItem>
|
||||||
|
</Select>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{filtered.length} von {properties.length} Objekten
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Card>
|
||||||
<Table size="small">
|
<Table size="small">
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow sx={{ bgcolor: 'grey.50' }}>
|
<TableRow sx={{ bgcolor: 'grey.50' }}>
|
||||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Objekt</Typography></TableCell>
|
<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 }}>Qualität</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 }}>Aktualitä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>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{sortedProperties.map(property => {
|
{filtered.map(property => {
|
||||||
const hasCritical = property.dataQuality.missingCriticalFields.length > 0
|
const q = property.dataQuality
|
||||||
const missingCritical = property.dataQuality.missingCriticalFields
|
const hasCritical = q.missingCriticalFields.length > 0
|
||||||
const missingOptional = property.dataQuality.missingOptionalFields
|
const actions = getRecommendedActions(q, q.freshness)
|
||||||
const score = property.dataQuality.score
|
const topAction = actions[0] ?? null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={property.id}
|
key={property.id}
|
||||||
hover
|
hover
|
||||||
sx={hasCritical ? { bgcolor: 'rgba(192,57,43,0.04)' } : {}}
|
sx={hasCritical ? { bgcolor: 'rgba(192,57,43,0.03)' } : {}}
|
||||||
>
|
>
|
||||||
{/* Objekt */}
|
|
||||||
<TableCell>
|
<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>
|
</TableCell>
|
||||||
|
|
||||||
{/* Quelle */}
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<DataQualityBadge quality={q} showLabel />
|
||||||
{getResultTypeLabel(property.resultType)}
|
|
||||||
</Typography>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* Score */}
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Box sx={{ width: 100 }}>
|
<FreshnessIndicator freshness={q.freshness} lastUpdated={property.sourceUpdatedAt} />
|
||||||
<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>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* Kritische Felder */}
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{missingCritical.length === 0 ? (
|
{q.missingCriticalFields.length === 0 ? (
|
||||||
<Chip label="Vollständig" color="success" size="small" />
|
<Chip label="Vollständig" color="success" size="small" />
|
||||||
) : (
|
) : (
|
||||||
<Box className="flex flex-wrap gap-1 items-center">
|
<Tooltip
|
||||||
{missingCritical.slice(0, 2).map(f => (
|
title={q.missingCriticalFields.join(', ')}
|
||||||
<Chip key={f} label={f} color="error" size="small" variant="outlined" />
|
arrow
|
||||||
))}
|
>
|
||||||
{missingCritical.length > 2 && (
|
<Chip
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600 }} color="error.main">
|
label={`${q.missingCriticalFields.length} fehlen`}
|
||||||
+{missingCritical.length - 2} weitere
|
color="error"
|
||||||
</Typography>
|
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>
|
</Box>
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
{/* Optionale Felder */}
|
|
||||||
<TableCell>
|
|
||||||
{missingOptional.length === 0 ? (
|
|
||||||
<Typography variant="caption" color="text.secondary">–</Typography>
|
|
||||||
) : (
|
) : (
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">—</Typography>
|
||||||
{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>
|
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -1,5 +1,133 @@
|
|||||||
import { MockupPropertyProvider } from '../provider/MockupPropertyProvider'
|
import { MockupPropertyProvider } from '../provider/MockupPropertyProvider'
|
||||||
|
import { FreshnessStatus } from '../domain/enums'
|
||||||
import type { DataQualitySummary } from '../domain/dashboard'
|
import type { DataQualitySummary } from '../domain/dashboard'
|
||||||
|
import type { DataQuality, Property } from '../domain/property'
|
||||||
|
|
||||||
|
export type RecommendedAction = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
detail: string
|
||||||
|
priority: 'HIGH' | 'MEDIUM' | 'LOW'
|
||||||
|
field?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Field → Action map ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const FIELD_ACTION_MAP: Record<string, Omit<RecommendedAction, 'id'>> = {
|
||||||
|
'Mietpreis/m²': { label: 'Mietpreis ergänzen', detail: 'Fehlender Mietpreis schließt Objekt aus Budget-Matches aus', priority: 'HIGH', field: 'Mietpreis/m²' },
|
||||||
|
'Fläche m²': { label: 'Fläche bestätigen', detail: 'Fläche ist Hard-Kriterium für alle Matchings', priority: 'HIGH', field: 'Fläche m²' },
|
||||||
|
'Verfügbarkeit': { label: 'Verfügbarkeit bestätigen', detail: 'Timing ist entscheidend für Nachfrager mit Deadlines', priority: 'HIGH', field: 'Verfügbarkeit' },
|
||||||
|
'Adresse': { label: 'Adresse vervollständigen', detail: 'Für Standortbewertung und Kartenansicht notwendig', priority: 'HIGH', field: 'Adresse' },
|
||||||
|
'Beschreibung': { label: 'Beschreibung hinzufügen', detail: 'Verbesserter Kontext erhöht Nachfrager-Vertrauen', priority: 'MEDIUM', field: 'Beschreibung' },
|
||||||
|
'Soft Factors': { label: 'Passantenfrequenz & ESG', detail: 'Soft Factors verbessern Match-Scoring erheblich', priority: 'MEDIUM', field: 'Soft Factors' },
|
||||||
|
'Ausbaustandard': { label: 'Ausbaustandard angeben', detail: 'SHELL/BASIC/FULL/PREMIUM beeinflusst Eignung stark', priority: 'MEDIUM', field: 'Ausbaustandard' },
|
||||||
|
'Bilder': { label: 'Bilder hochladen', detail: 'Objektfotos steigern Anfragerate deutlich', priority: 'MEDIUM', field: 'Bilder' },
|
||||||
|
'Jahresmiete (CHF)': { label: 'Jahresmiete angeben', detail: 'Ergänzt Mietpreis/m² für Budgetvergleiche', priority: 'LOW', field: 'Jahresmiete (CHF)' },
|
||||||
|
'Expansionspotenzial': { label: 'Erweiterungsfläche angeben', detail: 'Wichtig für wachsende Unternehmen', priority: 'LOW', field: 'Expansionspotenzial' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const FRESHNESS_ACTIONS: Record<string, RecommendedAction> = {
|
||||||
|
[FreshnessStatus.OUTDATED]: {
|
||||||
|
id: 'review_source',
|
||||||
|
label: 'Quelle überprüfen',
|
||||||
|
detail: 'Daten sind älter als 14 Tage — Verfügbarkeit könnte sich geändert haben',
|
||||||
|
priority: 'HIGH',
|
||||||
|
},
|
||||||
|
[FreshnessStatus.STALE]: {
|
||||||
|
id: 'update_data',
|
||||||
|
label: 'Daten aktualisieren',
|
||||||
|
detail: 'Daten sind 2–14 Tage alt — Aktualitätsscore reduziert',
|
||||||
|
priority: 'MEDIUM',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Core Checks ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CRITICAL_CHECKS: Array<{ field: string; present: (p: Property) => boolean }> = [
|
||||||
|
{ field: 'Mietpreis/m²', present: p => p.rentPricePerSqm > 0 },
|
||||||
|
{ field: 'Fläche m²', present: p => p.areaSqm > 0 },
|
||||||
|
{ field: 'Verfügbarkeit', present: p => !!p.availabilityDate },
|
||||||
|
{ field: 'Adresse', present: p => !!p.address?.street && !!p.address?.city },
|
||||||
|
]
|
||||||
|
|
||||||
|
const OPTIONAL_CHECKS: Array<{ field: string; present: (p: Property) => boolean }> = [
|
||||||
|
{ field: 'Beschreibung', present: p => !!p.description && p.description.length > 20 },
|
||||||
|
{ field: 'Soft Factors', present: p => !!(p.softFactors?.prestigeScore || p.softFactors?.footfallScore || p.softFactors?.commuterAccessScore) },
|
||||||
|
{ field: 'Ausbaustandard', present: p => !!p.hardFacts?.fitOut },
|
||||||
|
{ field: 'Bilder', present: p => (p.images?.length ?? 0) > 0 },
|
||||||
|
{ field: 'Jahresmiete (CHF)', present: p => !!p.rentChfSqmYear },
|
||||||
|
{ field: 'Expansionspotenzial', present: p => !!(p.expansionPotentialSqm || p.hardFacts) },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ── Public API ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getMissingCriticalFields(property: Property): string[] {
|
||||||
|
const fromData = property.dataQuality?.missingCriticalFields ?? []
|
||||||
|
if (fromData.length > 0) return fromData
|
||||||
|
return CRITICAL_CHECKS.filter(c => !c.present(property)).map(c => c.field)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQualityWarnings(property: Property): string[] {
|
||||||
|
return property.dataQuality?.warnings ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecommendedActions(quality: DataQuality, freshness?: string): RecommendedAction[] {
|
||||||
|
const actions: RecommendedAction[] = []
|
||||||
|
|
||||||
|
for (const field of quality.missingCriticalFields) {
|
||||||
|
const def = FIELD_ACTION_MAP[field]
|
||||||
|
if (def) actions.push({ id: `fill_${field}`, ...def })
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn = freshness ?? quality.freshness
|
||||||
|
if (fn && fn !== FreshnessStatus.FRESH) {
|
||||||
|
const freshnessAction = FRESHNESS_ACTIONS[fn]
|
||||||
|
if (freshnessAction) actions.push(freshnessAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of quality.missingOptionalFields) {
|
||||||
|
const def = FIELD_ACTION_MAP[field]
|
||||||
|
if (def) actions.push({ id: `fill_opt_${field}`, ...def })
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculatePropertyQuality(property: Property): DataQuality {
|
||||||
|
if (property.dataQuality?.qualityLevel) return property.dataQuality
|
||||||
|
|
||||||
|
const missingCritical = CRITICAL_CHECKS.filter(c => !c.present(property)).map(c => c.field)
|
||||||
|
const missingOptional = OPTIONAL_CHECKS.filter(c => !c.present(property)).map(c => c.field)
|
||||||
|
|
||||||
|
const completeness = 1 - (missingCritical.length * 0.15 + missingOptional.length * 0.05)
|
||||||
|
const confidence = property.confidenceScore ?? 0.5
|
||||||
|
|
||||||
|
const freshnessVal = property.dataQuality?.freshness ?? FreshnessStatus.OUTDATED
|
||||||
|
const freshnessFactor = freshnessVal === FreshnessStatus.FRESH ? 1 : freshnessVal === FreshnessStatus.STALE ? 0.7 : 0.4
|
||||||
|
|
||||||
|
const score = Math.min(1, Math.max(0, completeness * 0.5 + confidence * 0.3 + freshnessFactor * 0.2))
|
||||||
|
|
||||||
|
const warnings: string[] = []
|
||||||
|
if (confidence < 0.5) warnings.push('Niedrige Daten-Vertrauensscore')
|
||||||
|
if (freshnessVal === FreshnessStatus.OUTDATED) warnings.push('Daten sind veraltet (>14 Tage)')
|
||||||
|
if (missingCritical.length > 0) warnings.push(`${missingCritical.length} Pflichtfeld(er) fehlen`)
|
||||||
|
|
||||||
|
const qualityLevel = missingCritical.length > 0
|
||||||
|
? 'INCOMPLETE'
|
||||||
|
: score >= 0.8 ? 'HIGH' : score >= 0.6 ? 'MEDIUM' : 'LOW'
|
||||||
|
|
||||||
|
return {
|
||||||
|
score,
|
||||||
|
qualityLevel,
|
||||||
|
missingCriticalFields: missingCritical,
|
||||||
|
missingOptionalFields: missingOptional,
|
||||||
|
lastVerifiedAt: property.dataQuality?.lastVerifiedAt,
|
||||||
|
freshness: freshnessVal,
|
||||||
|
warnings,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Portfolio summary (existing) ──────────────────────────────────────────────
|
||||||
|
|
||||||
export const dataQualityService = {
|
export const dataQualityService = {
|
||||||
async getPortfolioQualitySummary(): Promise<DataQualitySummary> {
|
async getPortfolioQualitySummary(): Promise<DataQualitySummary> {
|
||||||
@@ -31,4 +159,9 @@ export const dataQualityService = {
|
|||||||
topMissingFields,
|
topMissingFields,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getMissingCriticalFields,
|
||||||
|
getQualityWarnings,
|
||||||
|
getRecommendedActions,
|
||||||
|
calculatePropertyQuality,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user