Files
property-match/src/pages/supply/DataQuality.tsx
T
Benjamin Sutter 6e67c698ce fix: standardize subtitle typography in all page headers
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>
2026-06-11 00:05:30 +02:00

414 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (6079%)', 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>
)
}