feat: F006 supply dashboard — complete spec implementation
- dataQualityService with getPortfolioQualitySummary() - Service methods: getDashboardPropertiesSummary, getStrongMatches, getSignalSummary, getDashboardTasks added to respective services - dashboardService uses Promise.allSettled for partial error resilience - KpiCard: onClick + Tooltip support - KpiGrid: navigate actions and tooltips per card - StrongMatchMiniCard: Match Center, Prüfung, Vergleichen action buttons - FutureSignalWidget: time horizon distribution (0–6, 6–12, 12–24 Mo.) - SupplyDashboard: DashboardHeader, empty state, error state, partial widget errors, permission-based rendering per UserRole Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Box, Card, CardContent, Divider, Typography } from '@mui/material'
|
||||
import { Box, Card, CardContent, Divider, LinearProgress, Typography } from '@mui/material'
|
||||
import { useNavigate } from 'react-router'
|
||||
import type { FutureSignalSummary } from '../../domain/dashboard'
|
||||
|
||||
interface FutureSignalWidgetProps {
|
||||
@@ -28,28 +29,57 @@ function StatItem({ label, value, highlight }: StatItemProps) {
|
||||
}
|
||||
|
||||
export function FutureSignalWidget({ summary }: FutureSignalWidgetProps) {
|
||||
const navigate = useNavigate()
|
||||
const total = summary.total || 1
|
||||
const dist = summary.timeHorizonDistribution
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent sx={{ p: 2.5 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Marktsignale
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Marktsignale
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: 'primary.main', cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}
|
||||
onClick={() => navigate('/supply/future-availability')}
|
||||
>
|
||||
Alle anzeigen
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: 2,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 2, mb: 2.5 }}>
|
||||
<StatItem label="Gesamt" value={summary.total} />
|
||||
<StatItem label="Hohe Konfidenz" value={summary.highConfidence} highlight />
|
||||
<StatItem label="Zu prüfen" value={summary.needsReview} />
|
||||
<StatItem
|
||||
label="Ø Zeithorizont (Monate)"
|
||||
value={summary.avgTimeHorizonMonths}
|
||||
/>
|
||||
<StatItem label="Ø Zeithorizont" value={`${summary.avgTimeHorizonMonths} Mon.`} />
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{/* Time horizon distribution */}
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600, display: 'block', mb: 1 }}>
|
||||
Zeithorizont-Verteilung
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
{[
|
||||
{ label: '0–6 Monate', count: dist.short },
|
||||
{ label: '6–12 Monate', count: dist.medium },
|
||||
{ label: '12–24 Monate', count: dist.long },
|
||||
].map(({ label, count }) => (
|
||||
<Box key={label}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>{label}</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600 }}>{count}</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={Math.round((count / total) * 100)}
|
||||
sx={{ height: 6, borderRadius: 3 }}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{summary.restricted > 0 && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Card, CardContent, Chip, Typography } from '@mui/material'
|
||||
import { Card, CardContent, CardActionArea, Chip, Tooltip, Typography } from '@mui/material'
|
||||
import type { KpiCardData } from '../../domain/dashboard'
|
||||
|
||||
interface KpiCardProps {
|
||||
@@ -9,32 +9,53 @@ export function KpiCard({ card }: KpiCardProps) {
|
||||
const trendColor =
|
||||
card.trend === 'up' ? 'success' : card.trend === 'down' ? 'error' : 'default'
|
||||
|
||||
return (
|
||||
const content = (
|
||||
<CardContent sx={{ p: 2.5 }}>
|
||||
<Typography
|
||||
variant="overline"
|
||||
sx={{ color: 'text.secondary', lineHeight: 1.4, display: 'block' }}
|
||||
>
|
||||
{card.label}
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, mt: 0.5, mb: 0.5 }}>
|
||||
{card.value}
|
||||
</Typography>
|
||||
{card.trendLabel && (
|
||||
<Chip
|
||||
label={card.trendLabel}
|
||||
size="small"
|
||||
color={trendColor as 'success' | 'error' | 'default'}
|
||||
sx={{ mt: 0.5 }}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
)
|
||||
|
||||
const card_ = (
|
||||
<Card
|
||||
sx={{
|
||||
borderTop: card.accent ? `4px solid ${card.accent}` : '4px solid transparent',
|
||||
height: '100%',
|
||||
cursor: card.onClick ? 'pointer' : 'default',
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 2.5 }}>
|
||||
<Typography
|
||||
variant="overline"
|
||||
sx={{ color: 'text.secondary', lineHeight: 1.4, display: 'block' }}
|
||||
>
|
||||
{card.label}
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, mt: 0.5, mb: 0.5 }}>
|
||||
{card.value}
|
||||
</Typography>
|
||||
{card.trendLabel && (
|
||||
<Chip
|
||||
label={card.trendLabel}
|
||||
size="small"
|
||||
color={trendColor as 'success' | 'error' | 'default'}
|
||||
sx={{ mt: 0.5 }}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
{card.onClick ? (
|
||||
<CardActionArea onClick={card.onClick} sx={{ height: '100%', alignItems: 'flex-start' }}>
|
||||
{content}
|
||||
</CardActionArea>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
|
||||
if (card.tooltip) {
|
||||
return (
|
||||
<Tooltip title={card.tooltip} placement="top" arrow>
|
||||
<span style={{ display: 'contents' }}>{card_}</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return card_
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Box } from '@mui/material'
|
||||
import { useNavigate } from 'react-router'
|
||||
import type { DashboardData, KpiCardData } from '../../domain/dashboard'
|
||||
import { KpiCard } from './KpiCard'
|
||||
|
||||
@@ -7,7 +8,8 @@ interface KpiGridProps {
|
||||
}
|
||||
|
||||
export function KpiGrid({ data }: KpiGridProps) {
|
||||
const pendingCount = data.reviewTasks.filter(t => t.status === 'PENDING').length
|
||||
const navigate = useNavigate()
|
||||
const pendingCount = data.reviewTasks?.filter(t => t.status === 'PENDING').length ?? 0
|
||||
|
||||
const qualityColor =
|
||||
data.avgDataQuality >= 80
|
||||
@@ -21,35 +23,47 @@ export function KpiGrid({ data }: KpiGridProps) {
|
||||
id: 'active-properties',
|
||||
label: 'Aktive Objekte',
|
||||
value: `${data.activeProperties} / ${data.totalProperties}`,
|
||||
tooltip: 'Objekte mit Status "Verfügbar jetzt" oder "Verfügbar bald"',
|
||||
onClick: () => navigate('/supply/properties'),
|
||||
},
|
||||
{
|
||||
id: 'strong-matches',
|
||||
label: 'Starke Matches',
|
||||
value: data.strongMatchCount,
|
||||
accent: '#1a7a4a',
|
||||
tooltip: 'Matches mit einem Match-Score ≥ 80',
|
||||
onClick: () => navigate('/supply/match-center'),
|
||||
},
|
||||
{
|
||||
id: 'avg-quality',
|
||||
label: 'Ø Datenqualität',
|
||||
value: `${data.avgDataQuality}%`,
|
||||
accent: qualityColor,
|
||||
tooltip: 'Durchschnittlicher Datenqualitäts-Score aller Objekte',
|
||||
onClick: () => navigate('/supply/data-quality'),
|
||||
},
|
||||
{
|
||||
id: 'market-signals',
|
||||
label: 'Marktsignale',
|
||||
value: data.futureSignals.total,
|
||||
value: data.futureSignals?.total ?? '–',
|
||||
tooltip: 'Erkannte Marktsignale und Future Availability Indikatoren',
|
||||
onClick: () => navigate('/supply/future-availability'),
|
||||
},
|
||||
{
|
||||
id: 'review-pending',
|
||||
label: 'Review ausstehend',
|
||||
value: pendingCount,
|
||||
accent: pendingCount > 0 ? '#d97706' : undefined,
|
||||
tooltip: 'Matches und Signale, die eine manuelle Prüfung erfordern',
|
||||
onClick: () => navigate('/ops/review-queue'),
|
||||
},
|
||||
{
|
||||
id: 'critical-gaps',
|
||||
label: 'Kritische Datenlücken',
|
||||
value: data.dataQuality.critical,
|
||||
accent: data.dataQuality.critical > 0 ? '#c0392b' : undefined,
|
||||
value: data.dataQuality?.critical ?? '–',
|
||||
accent: (data.dataQuality?.critical ?? 0) > 0 ? '#c0392b' : undefined,
|
||||
tooltip: 'Objekte mit Datenqualitäts-Score unter 50%',
|
||||
onClick: () => navigate('/supply/data-quality'),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Box, Card, CardContent, Chip, Typography } from '@mui/material'
|
||||
import { Box, Button, Card, CardContent, Chip, Typography } from '@mui/material'
|
||||
import { useNavigate } from 'react-router'
|
||||
import type { StrongMatchItem } from '../../domain/dashboard'
|
||||
|
||||
interface StrongMatchMiniCardProps {
|
||||
@@ -12,9 +13,11 @@ function scoreColor(score: number): string {
|
||||
}
|
||||
|
||||
export function StrongMatchMiniCard({ match }: StrongMatchMiniCardProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<Card variant="outlined" sx={{ mb: 1 }}>
|
||||
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
|
||||
<CardContent sx={{ p: 2, '&:last-child': { pb: 1.5 } }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, flex: 1 }}>
|
||||
{match.propertyTitle}
|
||||
@@ -31,13 +34,15 @@ export function StrongMatchMiniCard({ match }: StrongMatchMiniCardProps) {
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>
|
||||
{match.propertyAddress}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mt: 0.5 }}>
|
||||
{match.topReason}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 1, flexWrap: 'wrap' }}>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 0.75, mt: 1, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{match.missingDataCount > 0 && (
|
||||
<Chip
|
||||
label={`${match.missingDataCount} Felder fehlen`}
|
||||
@@ -45,13 +50,33 @@ export function StrongMatchMiniCard({ match }: StrongMatchMiniCardProps) {
|
||||
color="warning"
|
||||
/>
|
||||
)}
|
||||
{match.nextBestAction !== '–' && (
|
||||
<Chip
|
||||
label={match.nextBestAction}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 0.75, mt: 1.25, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
sx={{ textTransform: 'none', fontSize: '0.75rem', py: 0.25 }}
|
||||
onClick={() => navigate('/supply/match-center')}
|
||||
>
|
||||
Match Center
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ textTransform: 'none', fontSize: '0.75rem', py: 0.25 }}
|
||||
onClick={() => navigate('/ops/review-queue')}
|
||||
>
|
||||
Zur Prüfung
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ textTransform: 'none', fontSize: '0.75rem', py: 0.25 }}
|
||||
onClick={() => navigate('/demand/compare')}
|
||||
>
|
||||
Vergleichen
|
||||
</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user