feat: Datenpflege — actionable work queue with inline editing

- KPI cards now clickable filters (Pflichtfelder fehlen → filters list, etc.)
- Quality distribution bars also clickable filters
- Table rows sorted by urgency: critical+stale first, then critical, then stale
- Missing field names shown as red chips (not tooltip count): Mietpreis, Fläche, etc.
- Each row has Edit2 icon button + full row click → opens PropertyDetailView drawer
- Empfehlung column: top recommended action with priority icon (AlertTriangle/Clock)
- Active filter chip with × to clear
- DataFreshness stale highlighting in row background

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-24 23:57:12 +02:00
parent 6fb2fb027c
commit 98d2770054
+361 -179
View File
@@ -1,231 +1,413 @@
import { useState } from 'react'
import { memo, useMemo, useState } from 'react'
import {
Alert,
Box,
Card,
Chip,
Drawer,
IconButton,
LinearProgress,
MenuItem,
Select,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Tooltip,
Typography,
} from '@mui/material'
import { ErrorState, LoadingPage, SectionContainer } from '../../components/ui'
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'
type QualityFilter = '' | 'HIGH' | 'MEDIUM' | 'LOW' | 'INCOMPLETE'
// ── Types ────────────────────────────────────────────────────────────────────
function getQualityColor(score: number): 'success' | 'warning' | 'error' {
if (score >= 0.8) return 'success'
if (score >= 0.6) return 'warning'
return 'error'
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: '#1e3a5f' } }}
>
<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 [qualityFilter, setQualityFilter] = useState<QualityFilter>('')
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 avgScore = properties.length
? properties.reduce((sum, p) => sum + p.dataQuality.score, 0) / properties.length
const total = properties.length
const avgScore = total
? properties.reduce((s, p) => s + p.dataQuality.score, 0) / total
: 0
const criticalIssues = properties.filter(p => p.dataQuality.missingCriticalFields.length > 0)
const staleData = properties.filter(
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 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)
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 = [...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
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
})
.sort((a, b) => a.dataQuality.score - b.dataQuality.score)
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>
{/* Page Header */}
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid', borderColor: 'divider', px: 3, py: 2 }}>
<Typography variant="h5" sx={{ fontWeight: 700 }} color="text.primary">Datenpflege</Typography>
<Typography variant="body2" color="text.secondary">Vollständigkeit, Aktualität und Vertrauen der Objektdaten</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<Box sx={{ px: 3, py: 2, bgcolor: 'white', borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>Datenpflege</Typography>
<Typography variant="caption" color="text.secondary">
Vollständigkeit, Aktualität und Vertrauen der Objektdaten · Klick auf KPI-Karte filtert die Liste
</Typography>
</Box>
<Box sx={{ px: 3, py: 3 }} className="flex flex-col gap-4">
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3, display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Summary Stats */}
<Box className="grid grid-cols-3 gap-4">
<Card sx={{ p: 2.5 }}>
<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' }}>
{Math.round(avgScore * 100)}%
</Typography>
<LinearProgress variant="determinate" value={avgScore * 100} color={getQualityColor(avgScore)}
sx={{ height: 8, borderRadius: 4, mt: 1 }} />
</Card>
<Card sx={{ p: 2.5 }}>
<Typography variant="overline" color="text.secondary" sx={{ display: 'block' }}>Pflichtfelder fehlen</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: criticalIssues.length > 2 ? '#c0392b' : criticalIssues.length > 0 ? '#d97706' : '#1a7a4a' }}>
{criticalIssues.length}
</Typography>
<Typography variant="caption" color="text.secondary">von {properties.length} Objekten</Typography>
</Card>
<Card sx={{ p: 2.5 }}>
<Typography variant="overline" color="text.secondary" sx={{ display: 'block' }}>Veraltete Daten</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: staleData.length > 2 ? '#c0392b' : staleData.length > 0 ? '#d97706' : '#1a7a4a' }}>
{staleData.length}
</Typography>
<Typography variant="caption" color="text.secondary">von {properties.length} Objekten</Typography>
</Card>
</Box>
{/* Quality Distribution */}
<SectionContainer title="Qualitätsverteilung">
<Card sx={{ p: 2.5 }}>
<Box className="flex flex-col gap-3">
{[
{ label: 'Hoch (≥80%)', count: highQuality.length, color: 'success' as const },
{ label: 'Mittel (6079%)', count: medQuality.length, color: 'warning' as const },
{ label: 'Niedrig (<60%)', count: lowQuality.length, color: 'error' as const },
].map(row => (
<Box key={row.label} className="flex items-center gap-3">
<Typography variant="body2" sx={{ width: 120, flexShrink: 0 }}>{row.label}</Typography>
<Chip label={row.count} size="small" color={row.color} />
<Box className="flex-1">
<LinearProgress
variant="determinate"
value={properties.length > 0 ? (row.count / properties.length) * 100 : 0}
color={row.color}
sx={{ height: 10, borderRadius: 5 }}
{/* 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>
<Typography variant="caption" color="text.secondary" sx={{ width: 40, textAlign: 'right' }}>
{properties.length > 0 ? Math.round((row.count / properties.length) * 100) : 0}%
{/* 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>
</SectionContainer>
{/* Objects Table */}
<SectionContainer title="Objektübersicht">
{/* 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 (6079%)</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">
<TableHead>
<TableRow sx={{ bgcolor: 'grey.50' }}>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Objekt</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Qualität</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Aktualität</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>
</TableHead>
<TableBody>
{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 (
<TableRow
key={property.id}
hover
sx={hasCritical ? { bgcolor: 'rgba(192,57,43,0.03)' } : {}}
>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 500, mb: 0.25 }}>{property.title}</Typography>
<Typography variant="caption" color="text.secondary">{property.location.city}</Typography>
</TableCell>
<TableCell>
<DataQualityBadge quality={q} showLabel />
</TableCell>
<TableCell>
<FreshnessIndicator freshness={q.freshness} lastUpdated={property.sourceUpdatedAt} />
</TableCell>
<TableCell>
{q.missingCriticalFields.length === 0 ? (
<Chip label="Vollständig" color="success" size="small" />
) : (
<Tooltip
title={q.missingCriticalFields.join(', ')}
arrow
>
<Chip
label={`${q.missingCriticalFields.length} fehlen`}
color="error"
size="small"
variant="outlined"
/>
</Tooltip>
)}
</TableCell>
<TableCell>
{topAction ? (
{/* Object list */}
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: topAction.priority === 'HIGH' ? '#c0392b' : '#d97706' }}>
{topAction.label}
<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>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.65rem' }}>
{topAction.detail}
</Typography>
</Box>
) : (
<Typography variant="caption" color="text.secondary"></Typography>
{filter !== 'ALL' && (
<Chip
label={`Filter: ${filter}`}
size="small"
onDelete={() => setFilter('ALL')}
sx={{ height: 22, fontSize: '0.68rem' }}
/>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</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>
</SectionContainer>
) : (
<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>
)
}