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:
Benjamin Sutter
2026-05-15 16:31:54 +02:00
parent 46acca9e62
commit 7b7be0b436
12 changed files with 454 additions and 183 deletions
+165 -25
View File
@@ -1,8 +1,10 @@
import { Box } from '@mui/material'
import { Box, Alert, Button, Typography } from '@mui/material'
import { useNavigate } from 'react-router'
import { PageHeader } from '../../components/layout'
import { useSupplyDashboard } from '../../hooks/useSupplyDashboard'
import { useSessionStore } from '../../stores/sessionStore'
import { UserRole } from '../../domain/enums'
import {
DashboardHeader,
KpiGrid,
StrongMatchOverview,
DataQualityWidget,
@@ -12,36 +14,174 @@ import {
DashboardSkeleton,
} from '../../components/supply'
function WidgetError({ label }: { label: string }) {
return (
<Alert severity="error" sx={{ height: '100%' }}>
{label} konnte nicht geladen werden.
</Alert>
)
}
export default function SupplyDashboard() {
const { data, isLoading } = useSupplyDashboard()
const { data, isLoading, isError, refetch } = useSupplyDashboard()
const { currentUser } = useSessionStore()
const navigate = useNavigate()
if (isLoading || !data) return <DashboardSkeleton />
const role = currentUser?.role ?? UserRole.DEMAND_USER
const isReviewer = role === UserRole.REVIEWER
const isOwnerViewer = role === UserRole.OWNER_VIEWER
const canSeeMatches = role !== UserRole.OWNER_VIEWER && role !== UserRole.DEMAND_USER
const canSeeOperations =
role === UserRole.SUPER_ADMIN ||
role === UserRole.ORGANIZATION_ADMIN ||
role === UserRole.REVIEWER
if (isLoading) return <DashboardSkeleton />
if (isError) {
return (
<Box sx={{ p: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<Typography variant="h6" color="error">
Dashboard konnte nicht geladen werden
</Typography>
<Typography variant="body2" color="text.secondary">
Der Service ist vorübergehend nicht verfügbar.
</Typography>
<Button variant="outlined" onClick={() => refetch()}>
Erneut versuchen
</Button>
</Box>
)
}
if (!data || data.totalProperties === 0) {
return (
<Box sx={{ p: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<Typography variant="h6">Noch keine Objekte vorhanden</Typography>
<Typography variant="body2" color="text.secondary">
Fügen Sie Ihr erstes Objekt hinzu oder laden Sie Demo-Daten.
</Typography>
<Button variant="contained" onClick={() => navigate('/supply/properties')}>
Objekte verwalten
</Button>
</Box>
)
}
return (
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 3 }}>
<PageHeader
title="Supply Dashboard"
subtitle="Übersicht über Objekte, Matches und Datenqualität"
<DashboardHeader
orgName={currentUser?.organizationName}
lastUpdated={data.lastUpdated}
/>
<KpiGrid data={data} />
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 3 }}>
<StrongMatchOverview
matches={data.strongMatches}
onNavigate={() => navigate('/supply/match-center')}
/>
<DataQualityWidget
summary={data.dataQuality}
onNavigate={() => navigate('/supply/data-quality')}
/>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 3 }}>
<FutureSignalWidget summary={data.futureSignals} />
<ReviewTaskWidget
tasks={data.reviewTasks}
onNavigate={() => navigate('/ops/review-queue')}
/>
</Box>
{isOwnerViewer ? (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 2 }}>
<Box sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
<Typography variant="overline" color="text.secondary">
Aktive Objekte
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700 }}>
{data.activeProperties} / {data.totalProperties}
</Typography>
</Box>
<Box sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
<Typography variant="overline" color="text.secondary">
Ø Datenqualität
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700 }}>
{data.avgDataQuality}%
</Typography>
</Box>
</Box>
) : (
<KpiGrid data={data} />
)}
{/* REVIEWER: Review Queue first, then Matches */}
{isReviewer && (
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 3 }}>
{data.reviewTasks !== null ? (
<ReviewTaskWidget
tasks={data.reviewTasks}
onNavigate={() => navigate('/ops/review-queue')}
/>
) : (
<WidgetError label="Review Tasks" />
)}
{canSeeMatches &&
(data.strongMatches !== null ? (
<StrongMatchOverview
matches={data.strongMatches}
onNavigate={() => navigate('/supply/match-center')}
/>
) : (
<WidgetError label="Starke Matches" />
))}
</Box>
)}
{/* Default: Matches + Data Quality */}
{!isReviewer && canSeeMatches && (
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 3 }}>
{data.strongMatches !== null ? (
<StrongMatchOverview
matches={data.strongMatches}
onNavigate={() => navigate('/supply/match-center')}
/>
) : (
<WidgetError label="Starke Matches" />
)}
{data.dataQuality !== null ? (
<DataQualityWidget
summary={data.dataQuality}
onNavigate={() => navigate('/supply/data-quality')}
/>
) : (
<WidgetError label="Datenqualität" />
)}
</Box>
)}
{canSeeMatches && data.strongMatches?.length === 0 && (
<Alert
severity="info"
action={
<Button size="small" onClick={() => navigate('/demand/ai-search')}>
Bedarfsprofil erstellen
</Button>
}
>
Noch keine Matches vorhanden. Erstellen Sie ein Bedarfsprofil, um passende Objekte zu
finden.
</Alert>
)}
{!isOwnerViewer && (
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 3 }}>
{data.futureSignals !== null ? (
<FutureSignalWidget summary={data.futureSignals} />
) : (
<WidgetError label="Marktsignale" />
)}
{canSeeOperations && !isReviewer ? (
data.reviewTasks !== null ? (
<ReviewTaskWidget
tasks={data.reviewTasks}
onNavigate={() => navigate('/ops/review-queue')}
/>
) : (
<WidgetError label="Review Tasks" />
)
) : !canSeeOperations && data.dataQuality !== null ? (
<DataQualityWidget
summary={data.dataQuality}
onNavigate={() => navigate('/supply/data-quality')}
/>
) : null}
</Box>
)}
<QuickActionPanel />
</Box>
)