diff --git a/src/pages/supply/DataQuality.tsx b/src/pages/supply/DataQuality.tsx index 8b1574b..9ee3c4f 100644 --- a/src/pages/supply/DataQuality.tsx +++ b/src/pages/supply/DataQuality.tsx @@ -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 = { + '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 ( + 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 */} + + + {property.title} + + + {property.location.city} + + + + {/* Score */} + + + + + {/* Fehlende Pflichtfelder */} + + {hasCritical ? ( + <> + {visibleFields.map(f => ( + + ))} + {overflow > 0 && ( + + )} + + ) : ( + } + label="Vollständig" + size="small" + sx={{ height: 18, fontSize: '0.6rem', bgcolor: '#dcfce7', color: '#16a34a' }} + /> + )} + + + {/* Aktualität */} + + + + + {/* Empfehlung */} + + {topAction ? ( + + + {topAction.priority === 'HIGH' + ? + : isStale + ? + : + } + + {topAction.label} + + + + ) : ( + + )} + + + {/* Edit button */} + e.stopPropagation()}> + + onEdit(property.id)} + sx={{ color: '#94a3b8', '&:hover': { color: '#1e3a5f' } }} + > + + + + + + ) +}) + +// ── KPI Card ───────────────────────────────────────────────────────────────── + +function KpiCard({ + label, value, sub, color, active, onClick, +}: { + label: string + value: string | number + sub?: string + color: string + active: boolean + onClick: () => void +}) { + return ( + + + {label} + + + {value} + + {sub && {sub}} + + ) +} + +// ── Page ───────────────────────────────────────────────────────────────────── + export default function DataQuality() { - const [qualityFilter, setQualityFilter] = useState('') + const [filter, setFilter] = useState('ALL') + const [detailId, setDetailId] = useState(null) const { data: properties = [], isLoading, error } = useProperties() if (isLoading) return if (error) return - 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 ( - - {/* Page Header */} - - Datenpflege - Vollständigkeit, Aktualität und Vertrauen der Objektdaten + + {/* Header */} + + Datenpflege + + Vollständigkeit, Aktualität und Vertrauen der Objektdaten · Klick auf KPI-Karte filtert die Liste + - + - {/* Summary Stats */} - - - Ø Qualitätsscore - = 0.8 ? '#1a7a4a' : avgScore >= 0.6 ? '#d97706' : '#c0392b' }}> - {Math.round(avgScore * 100)}% - - - - - - Pflichtfelder fehlen - 2 ? '#c0392b' : criticalIssues.length > 0 ? '#d97706' : '#1a7a4a' }}> - {criticalIssues.length} - - von {properties.length} Objekten - - - - Veraltete Daten - 2 ? '#c0392b' : staleData.length > 0 ? '#d97706' : '#1a7a4a' }}> - {staleData.length} - - von {properties.length} Objekten - + {/* KPI cards */} + + setFilter('ALL')} + sub={`${total} Objekte insgesamt`} + /> + toggleFilter('INCOMPLETE')} + /> + toggleFilter('STALE')} + /> - {/* Quality Distribution */} - - - - {[ - { label: 'Hoch (≥80%)', count: highQuality.length, color: 'success' as const }, - { label: 'Mittel (60–79%)', count: medQuality.length, color: 'warning' as const }, - { label: 'Niedrig (<60%)', count: lowQuality.length, color: 'error' as const }, - ].map(row => ( - - {row.label} - - - 0 ? (row.count / properties.length) * 100 : 0} - color={row.color} - sx={{ height: 10, borderRadius: 5 }} - /> - - - {properties.length > 0 ? Math.round((row.count / properties.length) * 100) : 0}% - + {/* Quality distribution */} + + Qualitätsverteilung + + {[ + { 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 => ( + 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', + }} + > + + {row.label} + + + + 0 ? (row.count / total) * 100 : 0} + sx={{ + height: 10, borderRadius: 5, + bgcolor: `${row.color}18`, + '& .MuiLinearProgress-bar': { bgcolor: row.color, borderRadius: 5 }, + }} + /> - ))} - - - + + {total > 0 ? Math.round((row.count / total) * 100) : 0}% + + + ))} + + - {/* Objects Table */} - - {/* Filter bar */} - - + {/* Object list */} + + + Objektübersicht - {filtered.length} von {properties.length} Objekten + {filtered.length} von {total} Objekten · sortiert nach Handlungsbedarf + {filter !== 'ALL' && ( + setFilter('ALL')} + sx={{ height: 22, fontSize: '0.68rem' }} + /> + )} - - - - - Objekt - Qualität - Aktualität - Pflichtfelder - Nächste Massnahme - - - - {filtered.map(property => { - const q = property.dataQuality - const hasCritical = q.missingCriticalFields.length > 0 - const actions = getRecommendedActions(q, q.freshness) - const topAction = actions[0] ?? null + {filtered.length > 0 ? ( + + {/* Table header */} + + {['Objekt', 'Qualität', 'Fehlende Pflichtfelder', 'Aktualität', 'Empfehlung', ''].map(h => ( + + {h} + + ))} + - return ( - - - {property.title} - {property.location.city} - - - - - - - - - - - - {q.missingCriticalFields.length === 0 ? ( - - ) : ( - - - - )} - - - - {topAction ? ( - - - {topAction.label} - - - {topAction.detail} - - - ) : ( - - )} - - - ) - })} - -
-
-
+ {filtered.map(p => ( + + ))} + + ) : ( + + Keine Objekte in dieser Kategorie — alles in Ordnung! + + )} +
+ + {/* Edit drawer */} + setDetailId(null)} + slotProps={{ paper: { sx: { width: { xs: '100%', sm: 560 } } } }} + > + {detailId && ( + setDetailId(null)} /> + )} +
) }