Files
property-match/src/pages/supply/SupplyDashboard.tsx
T
Benjamin Sutter 7b7be0b436 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>
2026-05-15 16:31:54 +02:00

189 lines
6.2 KiB
TypeScript

import { Box, Alert, Button, Typography } from '@mui/material'
import { useNavigate } from 'react-router'
import { useSupplyDashboard } from '../../hooks/useSupplyDashboard'
import { useSessionStore } from '../../stores/sessionStore'
import { UserRole } from '../../domain/enums'
import {
DashboardHeader,
KpiGrid,
StrongMatchOverview,
DataQualityWidget,
FutureSignalWidget,
ReviewTaskWidget,
QuickActionPanel,
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, isError, refetch } = useSupplyDashboard()
const { currentUser } = useSessionStore()
const navigate = useNavigate()
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 }}>
<DashboardHeader
orgName={currentUser?.organizationName}
lastUpdated={data.lastUpdated}
/>
{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>
)
}