6e67c698ce
All page header subtitles now use variant="body2" color="text.secondary" (0.875rem gray). Fixed caption→body2 in DataQuality and MarketIntelligence, and aligned DashboardHeader title/subtitle to the global standard. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
414 lines
15 KiB
TypeScript
414 lines
15 KiB
TypeScript
import { memo, useMemo, useState } from 'react'
|
||
import {
|
||
Alert,
|
||
Box,
|
||
Card,
|
||
Chip,
|
||
Drawer,
|
||
IconButton,
|
||
LinearProgress,
|
||
Tooltip,
|
||
Typography,
|
||
} from '@mui/material'
|
||
import { AlertTriangle, CheckCircle2, Clock, Edit2, RefreshCw } from 'lucide-react'
|
||
import { LoadingPage, ErrorState } from '../../components/ui'
|
||
import { DataQualityBadge, FreshnessIndicator } from '../../components/data-quality'
|
||
import { PropertyDetailView } from '../../components/supply'
|
||
import { useProperties } from '../../hooks/useProperties'
|
||
import { getRecommendedActions } from '../../services/dataQualityService'
|
||
import { DataFreshness } from '../../domain/enums'
|
||
import type { Property } from '../../domain/property'
|
||
|
||
// ── Types ────────────────────────────────────────────────────────────────────
|
||
|
||
type QualityFilter = 'ALL' | 'INCOMPLETE' | 'STALE' | 'LOW' | 'MEDIUM' | 'HIGH'
|
||
|
||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
const FIELD_LABEL: Record<string, string> = {
|
||
'Mietpreis/m²': 'Mietpreis',
|
||
'Fläche m²': 'Fläche',
|
||
'Verfügbarkeit': 'Verfügbarkeit',
|
||
'Adresse': 'Adresse',
|
||
'Beschreibung': 'Beschreibung',
|
||
'Bilder': 'Bilder',
|
||
'Ausbaustandard': 'Ausbaustandard',
|
||
'Soft Factors': 'Soft Factors',
|
||
'Jahresmiete (CHF)': 'Jahresmiete',
|
||
'Expansionspotenzial': 'Erweiterung',
|
||
}
|
||
|
||
function priorityOf(p: Property): number {
|
||
const hasCritical = p.dataQuality.missingCriticalFields.length > 0
|
||
const isStale = p.dataQuality.freshness === DataFreshness.STALE || p.dataQuality.freshness === DataFreshness.OUTDATED
|
||
if (hasCritical && isStale) return 0
|
||
if (hasCritical) return 1
|
||
if (isStale) return 2
|
||
if (p.dataQuality.score < 0.6) return 3
|
||
if (p.dataQuality.score < 0.8) return 4
|
||
return 5
|
||
}
|
||
|
||
// ── Row ──────────────────────────────────────────────────────────────────────
|
||
|
||
const PropertyRow = memo(function PropertyRow({
|
||
property,
|
||
onEdit,
|
||
}: {
|
||
property: Property
|
||
onEdit: (id: string) => void
|
||
}) {
|
||
const q = property.dataQuality
|
||
const hasCritical = q.missingCriticalFields.length > 0
|
||
const isStale = q.freshness === DataFreshness.STALE || q.freshness === DataFreshness.OUTDATED
|
||
const topAction = getRecommendedActions(q, q.freshness)[0] ?? null
|
||
|
||
const visibleFields = q.missingCriticalFields.slice(0, 3)
|
||
const overflow = q.missingCriticalFields.length - visibleFields.length
|
||
|
||
return (
|
||
<Box
|
||
onClick={() => onEdit(property.id)}
|
||
sx={{
|
||
display: 'grid',
|
||
gridTemplateColumns: '2fr 110px 1fr 110px 110px 44px',
|
||
alignItems: 'center',
|
||
px: 2, py: 1.25,
|
||
borderBottom: '1px solid #f1f5f9',
|
||
cursor: 'pointer',
|
||
bgcolor: hasCritical ? '#fff8f8' : 'white',
|
||
'&:hover': { bgcolor: hasCritical ? '#fff0f0' : '#f8fafc' },
|
||
transition: 'background 0.1s',
|
||
gap: 1,
|
||
}}
|
||
>
|
||
{/* Objekt */}
|
||
<Box>
|
||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', lineHeight: 1.3 }}>
|
||
{property.title}
|
||
</Typography>
|
||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||
{property.location.city}
|
||
</Typography>
|
||
</Box>
|
||
|
||
{/* Score */}
|
||
<Box>
|
||
<DataQualityBadge quality={q} showLabel />
|
||
</Box>
|
||
|
||
{/* Fehlende Pflichtfelder */}
|
||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.4 }}>
|
||
{hasCritical ? (
|
||
<>
|
||
{visibleFields.map(f => (
|
||
<Chip
|
||
key={f}
|
||
label={FIELD_LABEL[f] ?? f}
|
||
size="small"
|
||
sx={{ height: 18, fontSize: '0.6rem', bgcolor: '#fee2e2', color: '#dc2626', fontWeight: 600 }}
|
||
/>
|
||
))}
|
||
{overflow > 0 && (
|
||
<Chip
|
||
label={`+${overflow}`}
|
||
size="small"
|
||
sx={{ height: 18, fontSize: '0.6rem', bgcolor: '#fee2e2', color: '#dc2626' }}
|
||
/>
|
||
)}
|
||
</>
|
||
) : (
|
||
<Chip
|
||
icon={<CheckCircle2 size={11} />}
|
||
label="Vollständig"
|
||
size="small"
|
||
sx={{ height: 18, fontSize: '0.6rem', bgcolor: '#dcfce7', color: '#16a34a' }}
|
||
/>
|
||
)}
|
||
</Box>
|
||
|
||
{/* Aktualität */}
|
||
<Box>
|
||
<FreshnessIndicator freshness={q.freshness} lastUpdated={property.sourceUpdatedAt} />
|
||
</Box>
|
||
|
||
{/* Empfehlung */}
|
||
<Box>
|
||
{topAction ? (
|
||
<Tooltip title={topAction.detail}>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||
{topAction.priority === 'HIGH'
|
||
? <AlertTriangle size={12} color="#dc2626" />
|
||
: isStale
|
||
? <Clock size={12} color="#d97706" />
|
||
: <RefreshCw size={12} color="#64748b" />
|
||
}
|
||
<Typography
|
||
variant="caption"
|
||
sx={{
|
||
color: topAction.priority === 'HIGH' ? '#dc2626' : '#d97706',
|
||
fontWeight: 600,
|
||
fontSize: '0.68rem',
|
||
lineHeight: 1.2,
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
>
|
||
{topAction.label}
|
||
</Typography>
|
||
</Box>
|
||
</Tooltip>
|
||
) : (
|
||
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.68rem' }}>—</Typography>
|
||
)}
|
||
</Box>
|
||
|
||
{/* Edit button */}
|
||
<Box onClick={e => e.stopPropagation()}>
|
||
<Tooltip title="Objekt bearbeiten">
|
||
<IconButton
|
||
size="small"
|
||
onClick={() => onEdit(property.id)}
|
||
sx={{ color: '#94a3b8', '&:hover': { color: '#152642' } }}
|
||
>
|
||
<Edit2 size={14} />
|
||
</IconButton>
|
||
</Tooltip>
|
||
</Box>
|
||
</Box>
|
||
)
|
||
})
|
||
|
||
// ── KPI Card ─────────────────────────────────────────────────────────────────
|
||
|
||
function KpiCard({
|
||
label, value, sub, color, active, onClick,
|
||
}: {
|
||
label: string
|
||
value: string | number
|
||
sub?: string
|
||
color: string
|
||
active: boolean
|
||
onClick: () => void
|
||
}) {
|
||
return (
|
||
<Card
|
||
onClick={onClick}
|
||
sx={{
|
||
p: 2.5,
|
||
cursor: 'pointer',
|
||
border: active ? `2px solid ${color}` : '1px solid #e2e8f0',
|
||
bgcolor: active ? `${color}08` : 'white',
|
||
transition: 'all 0.15s',
|
||
'&:hover': { borderColor: color, bgcolor: `${color}08` },
|
||
}}
|
||
>
|
||
<Typography variant="overline" color="text.secondary" sx={{ display: 'block', fontSize: '0.65rem' }}>
|
||
{label}
|
||
</Typography>
|
||
<Typography variant="h4" sx={{ fontWeight: 700, color, lineHeight: 1.2, my: 0.5 }}>
|
||
{value}
|
||
</Typography>
|
||
{sub && <Typography variant="caption" color="text.secondary">{sub}</Typography>}
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
// ── Page ─────────────────────────────────────────────────────────────────────
|
||
|
||
export default function DataQuality() {
|
||
const [filter, setFilter] = useState<QualityFilter>('ALL')
|
||
const [detailId, setDetailId] = useState<string | null>(null)
|
||
|
||
const { data: properties = [], isLoading, error } = useProperties()
|
||
|
||
if (isLoading) return <LoadingPage />
|
||
if (error) return <ErrorState />
|
||
|
||
const total = properties.length
|
||
|
||
const avgScore = total
|
||
? properties.reduce((s, p) => s + p.dataQuality.score, 0) / total
|
||
: 0
|
||
|
||
const incomplete = properties.filter(p => p.dataQuality.missingCriticalFields.length > 0)
|
||
const stale = properties.filter(
|
||
p => p.dataQuality.freshness === DataFreshness.STALE || p.dataQuality.freshness === DataFreshness.OUTDATED,
|
||
)
|
||
const high = properties.filter(p => p.dataQuality.score >= 0.8)
|
||
const medium = properties.filter(p => p.dataQuality.score >= 0.6 && p.dataQuality.score < 0.8)
|
||
const low = properties.filter(p => p.dataQuality.score < 0.6)
|
||
|
||
const filtered = useMemo(() => {
|
||
const base = [...properties]
|
||
const result = base.filter(p => {
|
||
if (filter === 'ALL') return true
|
||
if (filter === 'INCOMPLETE') return p.dataQuality.missingCriticalFields.length > 0
|
||
if (filter === 'STALE') return p.dataQuality.freshness === DataFreshness.STALE || p.dataQuality.freshness === DataFreshness.OUTDATED
|
||
if (filter === 'HIGH') return p.dataQuality.score >= 0.8
|
||
if (filter === 'MEDIUM') return p.dataQuality.score >= 0.6 && p.dataQuality.score < 0.8
|
||
if (filter === 'LOW') return p.dataQuality.score < 0.6
|
||
return true
|
||
})
|
||
return result.sort((a, b) => priorityOf(a) - priorityOf(b))
|
||
}, [properties, filter])
|
||
|
||
const scoreColor = avgScore >= 0.8 ? '#1a7a4a' : avgScore >= 0.6 ? '#d97706' : '#dc2626'
|
||
|
||
function toggleFilter(f: QualityFilter) {
|
||
setFilter(prev => prev === f ? 'ALL' : f)
|
||
}
|
||
|
||
return (
|
||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||
{/* Header */}
|
||
<Box sx={{ px: 3, py: 2.5, bgcolor: 'white', borderBottom: '1px solid #e8e7e4', flexShrink: 0 }}>
|
||
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Datenpflege</Typography>
|
||
<Typography variant="body2" color="text.secondary">
|
||
Vollständigkeit, Aktualität und Vertrauen der Objektdaten · Klick auf KPI-Karte filtert die Liste
|
||
</Typography>
|
||
</Box>
|
||
|
||
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||
|
||
{/* KPI cards */}
|
||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 2 }}>
|
||
<KpiCard
|
||
label="Ø Qualitätsscore"
|
||
value={`${Math.round(avgScore * 100)}%`}
|
||
color={scoreColor}
|
||
active={filter === 'ALL'}
|
||
onClick={() => setFilter('ALL')}
|
||
sub={`${total} Objekte insgesamt`}
|
||
/>
|
||
<KpiCard
|
||
label="Pflichtfelder fehlen"
|
||
value={incomplete.length}
|
||
sub={`von ${total} Objekten — klicken zum Filtern`}
|
||
color="#dc2626"
|
||
active={filter === 'INCOMPLETE'}
|
||
onClick={() => toggleFilter('INCOMPLETE')}
|
||
/>
|
||
<KpiCard
|
||
label="Veraltete Daten"
|
||
value={stale.length}
|
||
sub={`von ${total} Objekten — klicken zum Filtern`}
|
||
color="#d97706"
|
||
active={filter === 'STALE'}
|
||
onClick={() => toggleFilter('STALE')}
|
||
/>
|
||
</Box>
|
||
|
||
{/* Quality distribution */}
|
||
<Card sx={{ p: 2.5 }}>
|
||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.5 }}>Qualitätsverteilung</Typography>
|
||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||
{[
|
||
{ label: 'Hoch (≥80%)', count: high.length, color: '#16a34a', f: 'HIGH' as QualityFilter },
|
||
{ label: 'Mittel (60–79%)', count: medium.length, color: '#d97706', f: 'MEDIUM' as QualityFilter },
|
||
{ label: 'Niedrig (<60%)', count: low.length, color: '#dc2626', f: 'LOW' as QualityFilter },
|
||
].map(row => (
|
||
<Box
|
||
key={row.label}
|
||
onClick={() => toggleFilter(row.f)}
|
||
sx={{
|
||
display: 'flex', alignItems: 'center', gap: 2, cursor: 'pointer',
|
||
p: 0.75, borderRadius: 1,
|
||
bgcolor: filter === row.f ? `${row.color}10` : 'transparent',
|
||
'&:hover': { bgcolor: `${row.color}08` },
|
||
transition: 'background 0.1s',
|
||
}}
|
||
>
|
||
<Typography variant="body2" sx={{ width: 130, flexShrink: 0, fontWeight: filter === row.f ? 700 : 400 }}>
|
||
{row.label}
|
||
</Typography>
|
||
<Chip
|
||
label={row.count}
|
||
size="small"
|
||
sx={{ height: 20, fontSize: '0.7rem', fontWeight: 700, bgcolor: `${row.color}18`, color: row.color, flexShrink: 0 }}
|
||
/>
|
||
<Box sx={{ flex: 1 }}>
|
||
<LinearProgress
|
||
variant="determinate"
|
||
value={total > 0 ? (row.count / total) * 100 : 0}
|
||
sx={{
|
||
height: 10, borderRadius: 5,
|
||
bgcolor: `${row.color}18`,
|
||
'& .MuiLinearProgress-bar': { bgcolor: row.color, borderRadius: 5 },
|
||
}}
|
||
/>
|
||
</Box>
|
||
<Typography variant="caption" sx={{ color: '#64748b', width: 36, textAlign: 'right', flexShrink: 0 }}>
|
||
{total > 0 ? Math.round((row.count / total) * 100) : 0}%
|
||
</Typography>
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
</Card>
|
||
|
||
{/* Object list */}
|
||
<Box>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||
<Typography variant="subtitle2" sx={{ fontWeight: 700, flex: 1 }}>Objektübersicht</Typography>
|
||
<Typography variant="caption" color="text.secondary">
|
||
{filtered.length} von {total} Objekten · sortiert nach Handlungsbedarf
|
||
</Typography>
|
||
{filter !== 'ALL' && (
|
||
<Chip
|
||
label={`Filter: ${filter}`}
|
||
size="small"
|
||
onDelete={() => setFilter('ALL')}
|
||
sx={{ height: 22, fontSize: '0.68rem' }}
|
||
/>
|
||
)}
|
||
</Box>
|
||
|
||
{filtered.length > 0 ? (
|
||
<Card sx={{ overflow: 'hidden' }}>
|
||
{/* Table header */}
|
||
<Box
|
||
sx={{
|
||
display: 'grid',
|
||
gridTemplateColumns: '2fr 110px 1fr 110px 110px 44px',
|
||
px: 2, py: 1,
|
||
bgcolor: '#f8fafc',
|
||
borderBottom: '1px solid #e2e8f0',
|
||
gap: 1,
|
||
}}
|
||
>
|
||
{['Objekt', 'Qualität', 'Fehlende Pflichtfelder', 'Aktualität', 'Empfehlung', ''].map(h => (
|
||
<Typography key={h} variant="caption" sx={{ fontWeight: 700, color: '#64748b', fontSize: '0.65rem', textTransform: 'uppercase' }}>
|
||
{h}
|
||
</Typography>
|
||
))}
|
||
</Box>
|
||
|
||
{filtered.map(p => (
|
||
<PropertyRow key={p.id} property={p} onEdit={setDetailId} />
|
||
))}
|
||
</Card>
|
||
) : (
|
||
<Alert severity="success" sx={{ mt: 1 }}>
|
||
Keine Objekte in dieser Kategorie — alles in Ordnung!
|
||
</Alert>
|
||
)}
|
||
</Box>
|
||
|
||
</Box>
|
||
|
||
{/* Edit drawer */}
|
||
<Drawer
|
||
anchor="right"
|
||
open={!!detailId}
|
||
onClose={() => setDetailId(null)}
|
||
slotProps={{ paper: { sx: { width: { xs: '100%', sm: 560 } } } }}
|
||
>
|
||
{detailId && (
|
||
<PropertyDetailView propertyId={detailId} onClose={() => setDetailId(null)} />
|
||
)}
|
||
</Drawer>
|
||
</Box>
|
||
)
|
||
}
|