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,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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user