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'
|
import type { FutureSignalSummary } from '../../domain/dashboard'
|
||||||
|
|
||||||
interface FutureSignalWidgetProps {
|
interface FutureSignalWidgetProps {
|
||||||
@@ -28,28 +29,57 @@ function StatItem({ label, value, highlight }: StatItemProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FutureSignalWidget({ summary }: FutureSignalWidgetProps) {
|
export function FutureSignalWidget({ summary }: FutureSignalWidgetProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const total = summary.total || 1
|
||||||
|
const dist = summary.timeHorizonDistribution
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent sx={{ p: 2.5 }}>
|
<CardContent sx={{ p: 2.5 }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
Marktsignale
|
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||||
</Typography>
|
Marktsignale
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ color: 'primary.main', cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}
|
||||||
|
onClick={() => navigate('/supply/future-availability')}
|
||||||
|
>
|
||||||
|
Alle anzeigen
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Box
|
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 2, mb: 2.5 }}>
|
||||||
sx={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
|
||||||
gap: 2,
|
|
||||||
mb: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<StatItem label="Gesamt" value={summary.total} />
|
<StatItem label="Gesamt" value={summary.total} />
|
||||||
<StatItem label="Hohe Konfidenz" value={summary.highConfidence} highlight />
|
<StatItem label="Hohe Konfidenz" value={summary.highConfidence} highlight />
|
||||||
<StatItem label="Zu prüfen" value={summary.needsReview} />
|
<StatItem label="Zu prüfen" value={summary.needsReview} />
|
||||||
<StatItem
|
<StatItem label="Ø Zeithorizont" value={`${summary.avgTimeHorizonMonths} Mon.`} />
|
||||||
label="Ø Zeithorizont (Monate)"
|
</Box>
|
||||||
value={summary.avgTimeHorizonMonths}
|
|
||||||
/>
|
<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>
|
</Box>
|
||||||
|
|
||||||
{summary.restricted > 0 && (
|
{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'
|
import type { KpiCardData } from '../../domain/dashboard'
|
||||||
|
|
||||||
interface KpiCardProps {
|
interface KpiCardProps {
|
||||||
@@ -9,32 +9,53 @@ export function KpiCard({ card }: KpiCardProps) {
|
|||||||
const trendColor =
|
const trendColor =
|
||||||
card.trend === 'up' ? 'success' : card.trend === 'down' ? 'error' : 'default'
|
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
|
<Card
|
||||||
sx={{
|
sx={{
|
||||||
borderTop: card.accent ? `4px solid ${card.accent}` : '4px solid transparent',
|
borderTop: card.accent ? `4px solid ${card.accent}` : '4px solid transparent',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
cursor: card.onClick ? 'pointer' : 'default',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ p: 2.5 }}>
|
{card.onClick ? (
|
||||||
<Typography
|
<CardActionArea onClick={card.onClick} sx={{ height: '100%', alignItems: 'flex-start' }}>
|
||||||
variant="overline"
|
{content}
|
||||||
sx={{ color: 'text.secondary', lineHeight: 1.4, display: 'block' }}
|
</CardActionArea>
|
||||||
>
|
) : (
|
||||||
{card.label}
|
content
|
||||||
</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>
|
</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 { Box } from '@mui/material'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
import type { DashboardData, KpiCardData } from '../../domain/dashboard'
|
import type { DashboardData, KpiCardData } from '../../domain/dashboard'
|
||||||
import { KpiCard } from './KpiCard'
|
import { KpiCard } from './KpiCard'
|
||||||
|
|
||||||
@@ -7,7 +8,8 @@ interface KpiGridProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function KpiGrid({ data }: 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 =
|
const qualityColor =
|
||||||
data.avgDataQuality >= 80
|
data.avgDataQuality >= 80
|
||||||
@@ -21,35 +23,47 @@ export function KpiGrid({ data }: KpiGridProps) {
|
|||||||
id: 'active-properties',
|
id: 'active-properties',
|
||||||
label: 'Aktive Objekte',
|
label: 'Aktive Objekte',
|
||||||
value: `${data.activeProperties} / ${data.totalProperties}`,
|
value: `${data.activeProperties} / ${data.totalProperties}`,
|
||||||
|
tooltip: 'Objekte mit Status "Verfügbar jetzt" oder "Verfügbar bald"',
|
||||||
|
onClick: () => navigate('/supply/properties'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'strong-matches',
|
id: 'strong-matches',
|
||||||
label: 'Starke Matches',
|
label: 'Starke Matches',
|
||||||
value: data.strongMatchCount,
|
value: data.strongMatchCount,
|
||||||
accent: '#1a7a4a',
|
accent: '#1a7a4a',
|
||||||
|
tooltip: 'Matches mit einem Match-Score ≥ 80',
|
||||||
|
onClick: () => navigate('/supply/match-center'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'avg-quality',
|
id: 'avg-quality',
|
||||||
label: 'Ø Datenqualität',
|
label: 'Ø Datenqualität',
|
||||||
value: `${data.avgDataQuality}%`,
|
value: `${data.avgDataQuality}%`,
|
||||||
accent: qualityColor,
|
accent: qualityColor,
|
||||||
|
tooltip: 'Durchschnittlicher Datenqualitäts-Score aller Objekte',
|
||||||
|
onClick: () => navigate('/supply/data-quality'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'market-signals',
|
id: 'market-signals',
|
||||||
label: 'Marktsignale',
|
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',
|
id: 'review-pending',
|
||||||
label: 'Review ausstehend',
|
label: 'Review ausstehend',
|
||||||
value: pendingCount,
|
value: pendingCount,
|
||||||
accent: pendingCount > 0 ? '#d97706' : undefined,
|
accent: pendingCount > 0 ? '#d97706' : undefined,
|
||||||
|
tooltip: 'Matches und Signale, die eine manuelle Prüfung erfordern',
|
||||||
|
onClick: () => navigate('/ops/review-queue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'critical-gaps',
|
id: 'critical-gaps',
|
||||||
label: 'Kritische Datenlücken',
|
label: 'Kritische Datenlücken',
|
||||||
value: data.dataQuality.critical,
|
value: data.dataQuality?.critical ?? '–',
|
||||||
accent: data.dataQuality.critical > 0 ? '#c0392b' : undefined,
|
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'
|
import type { StrongMatchItem } from '../../domain/dashboard'
|
||||||
|
|
||||||
interface StrongMatchMiniCardProps {
|
interface StrongMatchMiniCardProps {
|
||||||
@@ -12,9 +13,11 @@ function scoreColor(score: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function StrongMatchMiniCard({ match }: StrongMatchMiniCardProps) {
|
export function StrongMatchMiniCard({ match }: StrongMatchMiniCardProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card variant="outlined" sx={{ mb: 1 }}>
|
<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 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 0.5 }}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, flex: 1 }}>
|
<Typography variant="body2" sx={{ fontWeight: 600, flex: 1 }}>
|
||||||
{match.propertyTitle}
|
{match.propertyTitle}
|
||||||
@@ -31,13 +34,15 @@ export function StrongMatchMiniCard({ match }: StrongMatchMiniCardProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>
|
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>
|
||||||
{match.propertyAddress}
|
{match.propertyAddress}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mt: 0.5 }}>
|
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mt: 0.5 }}>
|
||||||
{match.topReason}
|
{match.topReason}
|
||||||
</Typography>
|
</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 && (
|
{match.missingDataCount > 0 && (
|
||||||
<Chip
|
<Chip
|
||||||
label={`${match.missingDataCount} Felder fehlen`}
|
label={`${match.missingDataCount} Felder fehlen`}
|
||||||
@@ -45,13 +50,33 @@ export function StrongMatchMiniCard({ match }: StrongMatchMiniCardProps) {
|
|||||||
color="warning"
|
color="warning"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{match.nextBestAction !== '–' && (
|
</Box>
|
||||||
<Chip
|
|
||||||
label={match.nextBestAction}
|
<Box sx={{ display: 'flex', gap: 0.75, mt: 1.25, flexWrap: 'wrap' }}>
|
||||||
size="small"
|
<Button
|
||||||
variant="outlined"
|
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>
|
</Box>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
+13
-4
@@ -5,6 +5,8 @@ export interface KpiCardData {
|
|||||||
trend?: 'up' | 'down' | 'neutral'
|
trend?: 'up' | 'down' | 'neutral'
|
||||||
trendLabel?: string
|
trendLabel?: string
|
||||||
accent?: string
|
accent?: string
|
||||||
|
tooltip?: string
|
||||||
|
onClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StrongMatchItem {
|
export interface StrongMatchItem {
|
||||||
@@ -19,12 +21,19 @@ export interface StrongMatchItem {
|
|||||||
nextBestAction: string
|
nextBestAction: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TimeHorizonDistribution {
|
||||||
|
short: number // 0–6 months
|
||||||
|
medium: number // 6–12 months
|
||||||
|
long: number // 12–24 months
|
||||||
|
}
|
||||||
|
|
||||||
export interface FutureSignalSummary {
|
export interface FutureSignalSummary {
|
||||||
total: number
|
total: number
|
||||||
highConfidence: number
|
highConfidence: number
|
||||||
restricted: number
|
restricted: number
|
||||||
needsReview: number
|
needsReview: number
|
||||||
avgTimeHorizonMonths: number
|
avgTimeHorizonMonths: number
|
||||||
|
timeHorizonDistribution: TimeHorizonDistribution
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DataQualitySummary {
|
export interface DataQualitySummary {
|
||||||
@@ -47,9 +56,9 @@ export interface DashboardData {
|
|||||||
activeProperties: number
|
activeProperties: number
|
||||||
strongMatchCount: number
|
strongMatchCount: number
|
||||||
avgDataQuality: number
|
avgDataQuality: number
|
||||||
futureSignals: FutureSignalSummary
|
futureSignals: FutureSignalSummary | null
|
||||||
dataQuality: DataQualitySummary
|
dataQuality: DataQualitySummary | null
|
||||||
reviewTasks: DashboardReviewTask[]
|
reviewTasks: DashboardReviewTask[] | null
|
||||||
strongMatches: StrongMatchItem[]
|
strongMatches: StrongMatchItem[] | null
|
||||||
lastUpdated: string
|
lastUpdated: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Box } from '@mui/material'
|
import { Box, Alert, Button, Typography } from '@mui/material'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { PageHeader } from '../../components/layout'
|
|
||||||
import { useSupplyDashboard } from '../../hooks/useSupplyDashboard'
|
import { useSupplyDashboard } from '../../hooks/useSupplyDashboard'
|
||||||
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
|
import { UserRole } from '../../domain/enums'
|
||||||
import {
|
import {
|
||||||
|
DashboardHeader,
|
||||||
KpiGrid,
|
KpiGrid,
|
||||||
StrongMatchOverview,
|
StrongMatchOverview,
|
||||||
DataQualityWidget,
|
DataQualityWidget,
|
||||||
@@ -12,36 +14,174 @@ import {
|
|||||||
DashboardSkeleton,
|
DashboardSkeleton,
|
||||||
} from '../../components/supply'
|
} 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() {
|
export default function SupplyDashboard() {
|
||||||
const { data, isLoading } = useSupplyDashboard()
|
const { data, isLoading, isError, refetch } = useSupplyDashboard()
|
||||||
|
const { currentUser } = useSessionStore()
|
||||||
const navigate = useNavigate()
|
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 (
|
return (
|
||||||
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
<PageHeader
|
<DashboardHeader
|
||||||
title="Supply Dashboard"
|
orgName={currentUser?.organizationName}
|
||||||
subtitle="Übersicht über Objekte, Matches und Datenqualität"
|
lastUpdated={data.lastUpdated}
|
||||||
/>
|
/>
|
||||||
<KpiGrid data={data} />
|
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 3 }}>
|
{isOwnerViewer ? (
|
||||||
<StrongMatchOverview
|
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 2 }}>
|
||||||
matches={data.strongMatches}
|
<Box sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
|
||||||
onNavigate={() => navigate('/supply/match-center')}
|
<Typography variant="overline" color="text.secondary">
|
||||||
/>
|
Aktive Objekte
|
||||||
<DataQualityWidget
|
</Typography>
|
||||||
summary={data.dataQuality}
|
<Typography variant="h4" sx={{ fontWeight: 700 }}>
|
||||||
onNavigate={() => navigate('/supply/data-quality')}
|
{data.activeProperties} / {data.totalProperties}
|
||||||
/>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 3 }}>
|
<Box sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
|
||||||
<FutureSignalWidget summary={data.futureSignals} />
|
<Typography variant="overline" color="text.secondary">
|
||||||
<ReviewTaskWidget
|
Ø Datenqualität
|
||||||
tasks={data.reviewTasks}
|
</Typography>
|
||||||
onNavigate={() => navigate('/ops/review-queue')}
|
<Typography variant="h4" sx={{ fontWeight: 700 }}>
|
||||||
/>
|
{data.avgDataQuality}%
|
||||||
</Box>
|
</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 />
|
<QuickActionPanel />
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,113 +1,34 @@
|
|||||||
import { MockupPropertyProvider } from '../provider/MockupPropertyProvider'
|
import { propertyService } from './propertyService'
|
||||||
import { MockupMatchProvider } from '../provider/MockupMatchProvider'
|
import { matchService } from './matchService'
|
||||||
import { MockupFutureSignalProvider } from '../provider/MockupFutureSignalProvider'
|
import { futureSignalService } from './futureSignalService'
|
||||||
import { MockupReviewProvider } from '../provider/MockupReviewProvider'
|
import { dataQualityService } from './dataQualityService'
|
||||||
import type {
|
import { reviewService } from './reviewService'
|
||||||
DashboardData,
|
import type { DashboardData } from '../domain/dashboard'
|
||||||
StrongMatchItem,
|
|
||||||
DashboardReviewTask,
|
|
||||||
} from '../domain/dashboard'
|
|
||||||
|
|
||||||
const ACTIVE_STATUSES = ['AVAILABLE_NOW', 'AVAILABLE_SOON'] as const
|
|
||||||
|
|
||||||
function isActiveStatus(s: string): s is typeof ACTIVE_STATUSES[number] {
|
|
||||||
return (ACTIVE_STATUSES as readonly string[]).includes(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const dashboardService = {
|
export const dashboardService = {
|
||||||
async getDashboardData(): Promise<DashboardData> {
|
async getDashboardData(): Promise<DashboardData> {
|
||||||
const [properties, matches, signals, reviewItems] = await Promise.all([
|
const [propRes, matchRes, signalRes, qualityRes, reviewRes] = await Promise.allSettled([
|
||||||
MockupPropertyProvider.getAll(),
|
propertyService.getDashboardPropertiesSummary(),
|
||||||
MockupMatchProvider.getAll(),
|
matchService.getStrongMatches(),
|
||||||
MockupFutureSignalProvider.getAll(),
|
futureSignalService.getSignalSummary(),
|
||||||
MockupReviewProvider.getQueue(),
|
dataQualityService.getPortfolioQualitySummary(),
|
||||||
|
reviewService.getDashboardTasks(),
|
||||||
])
|
])
|
||||||
|
|
||||||
const activeProperties = properties.filter(p =>
|
const propSummary = propRes.status === 'fulfilled' ? propRes.value : null
|
||||||
isActiveStatus(p.availabilityStatus),
|
const strongMatches = matchRes.status === 'fulfilled' ? matchRes.value : null
|
||||||
)
|
const signals = signalRes.status === 'fulfilled' ? signalRes.value : null
|
||||||
|
const quality = qualityRes.status === 'fulfilled' ? qualityRes.value : null
|
||||||
const strongMatches: StrongMatchItem[] = matches
|
const tasks = reviewRes.status === 'fulfilled' ? reviewRes.value : null
|
||||||
.filter(m => m.matchScore >= 80)
|
|
||||||
.slice(0, 5)
|
|
||||||
.map(m => {
|
|
||||||
const prop = properties.find(p => p.id === m.propertyId)
|
|
||||||
const topFactor = m.positiveFactors[0]
|
|
||||||
const firstAction = m.nextBestActions?.[0]
|
|
||||||
return {
|
|
||||||
matchId: m.id,
|
|
||||||
propertyId: m.propertyId,
|
|
||||||
propertyTitle: prop?.title ?? 'Unbekanntes Objekt',
|
|
||||||
propertyAddress: prop?.address?.street
|
|
||||||
? `${prop.address.street} ${prop.address.houseNumber}, ${prop.address.city}`
|
|
||||||
: '–',
|
|
||||||
needSummary: m.needId,
|
|
||||||
matchScore: m.matchScore,
|
|
||||||
topReason: topFactor?.explanation ?? topFactor?.criterion ?? '–',
|
|
||||||
missingDataCount: m.missingData?.length ?? 0,
|
|
||||||
nextBestAction: firstAction?.label ?? '–',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const avgQualityRaw =
|
|
||||||
properties.length > 0
|
|
||||||
? properties.reduce((sum, p) => sum + (p.dataQuality?.score ?? 0), 0) /
|
|
||||||
properties.length
|
|
||||||
: 0
|
|
||||||
const avgQuality = Math.round(avgQualityRaw * 100)
|
|
||||||
|
|
||||||
const fieldCounts = properties
|
|
||||||
.flatMap(p => p.dataQuality?.missingCriticalFields ?? [])
|
|
||||||
.reduce<Record<string, number>>((acc, f) => {
|
|
||||||
acc[f] = (acc[f] ?? 0) + 1
|
|
||||||
return acc
|
|
||||||
}, {})
|
|
||||||
|
|
||||||
const topMissing = Object.entries(fieldCounts)
|
|
||||||
.sort((a, b) => b[1] - a[1])
|
|
||||||
.slice(0, 5)
|
|
||||||
.map(([f]) => f)
|
|
||||||
|
|
||||||
const highConfidenceSignals = signals.filter(s => s.confidenceScore >= 0.75)
|
|
||||||
const restrictedSignals = signals.filter(
|
|
||||||
s => s.sensitivityLevel === 'CONFIDENTIAL' || s.sensitivityLevel === 'INTERNAL',
|
|
||||||
)
|
|
||||||
|
|
||||||
const reviewTasks: DashboardReviewTask[] = reviewItems.slice(0, 8).map(r => ({
|
|
||||||
id: r.id,
|
|
||||||
title: `Match ${r.matchId} – Objekt ${r.propertyId}`,
|
|
||||||
priority: r.priority as 'HIGH' | 'MEDIUM' | 'LOW',
|
|
||||||
status: r.status,
|
|
||||||
type: 'REVIEW',
|
|
||||||
}))
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalProperties: properties.length,
|
totalProperties: propSummary?.total ?? 0,
|
||||||
activeProperties: activeProperties.length,
|
activeProperties: propSummary?.active ?? 0,
|
||||||
strongMatchCount: matches.filter(m => m.matchScore >= 80).length,
|
strongMatchCount: strongMatches?.length ?? 0,
|
||||||
avgDataQuality: avgQuality,
|
avgDataQuality: quality?.avgScore ?? 0,
|
||||||
futureSignals: {
|
futureSignals: signals,
|
||||||
total: signals.length,
|
dataQuality: quality,
|
||||||
highConfidence: highConfidenceSignals.length,
|
reviewTasks: tasks,
|
||||||
restricted: restrictedSignals.length,
|
|
||||||
needsReview: signals.filter(s => !s.isVerified).length,
|
|
||||||
avgTimeHorizonMonths:
|
|
||||||
signals.length > 0
|
|
||||||
? Math.round(
|
|
||||||
signals.reduce((sum, s) => sum + (s.timeHorizonMonths ?? 0), 0) /
|
|
||||||
signals.length,
|
|
||||||
)
|
|
||||||
: 0,
|
|
||||||
},
|
|
||||||
dataQuality: {
|
|
||||||
avgScore: avgQuality,
|
|
||||||
critical: properties.filter(p => (p.dataQuality?.score ?? 0) < 0.5).length,
|
|
||||||
propertiesWithMissingCritical: properties.filter(
|
|
||||||
p => (p.dataQuality?.missingCriticalFields?.length ?? 0) > 0,
|
|
||||||
).length,
|
|
||||||
topMissingFields: topMissing,
|
|
||||||
},
|
|
||||||
reviewTasks,
|
|
||||||
strongMatches,
|
strongMatches,
|
||||||
lastUpdated: new Date().toISOString(),
|
lastUpdated: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { MockupPropertyProvider } from '../provider/MockupPropertyProvider'
|
||||||
|
import type { DataQualitySummary } from '../domain/dashboard'
|
||||||
|
|
||||||
|
export const dataQualityService = {
|
||||||
|
async getPortfolioQualitySummary(): Promise<DataQualitySummary> {
|
||||||
|
const properties = await MockupPropertyProvider.getAll()
|
||||||
|
|
||||||
|
const avgScoreRaw =
|
||||||
|
properties.length > 0
|
||||||
|
? properties.reduce((sum, p) => sum + (p.dataQuality?.score ?? 0), 0) / properties.length
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const fieldCounts = properties
|
||||||
|
.flatMap(p => p.dataQuality?.missingCriticalFields ?? [])
|
||||||
|
.reduce<Record<string, number>>((acc, f) => {
|
||||||
|
acc[f] = (acc[f] ?? 0) + 1
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
const topMissingFields = Object.entries(fieldCounts)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(([f]) => f)
|
||||||
|
|
||||||
|
return {
|
||||||
|
avgScore: Math.round(avgScoreRaw * 100),
|
||||||
|
critical: properties.filter(p => (p.dataQuality?.score ?? 0) < 0.5).length,
|
||||||
|
propertiesWithMissingCritical: properties.filter(
|
||||||
|
p => (p.dataQuality?.missingCriticalFields?.length ?? 0) > 0,
|
||||||
|
).length,
|
||||||
|
topMissingFields,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { MockupFutureSignalProvider } from '../provider/MockupFutureSignalProvider'
|
import { MockupFutureSignalProvider } from '../provider/MockupFutureSignalProvider'
|
||||||
import type { FutureSignalFilters } from '../provider/IFutureSignalProvider'
|
import type { FutureSignalFilters } from '../provider/IFutureSignalProvider'
|
||||||
import type { FutureSignal } from '../domain/futureSignal'
|
import type { FutureSignal } from '../domain/futureSignal'
|
||||||
|
import type { FutureSignalSummary } from '../domain/dashboard'
|
||||||
import type { ListResponse, ItemResponse } from './types'
|
import type { ListResponse, ItemResponse } from './types'
|
||||||
|
|
||||||
const provider = MockupFutureSignalProvider
|
const provider = MockupFutureSignalProvider
|
||||||
@@ -22,4 +23,24 @@ export const futureSignalService = {
|
|||||||
const data = await provider.verify(id, verifiedBy)
|
const data = await provider.verify(id, verifiedBy)
|
||||||
return { data }
|
return { data }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getSignalSummary(): Promise<FutureSignalSummary> {
|
||||||
|
const signals = await provider.getAll()
|
||||||
|
const RESTRICTED: readonly string[] = ['CONFIDENTIAL', 'INTERNAL']
|
||||||
|
return {
|
||||||
|
total: signals.length,
|
||||||
|
highConfidence: signals.filter(s => s.confidenceScore >= 0.75).length,
|
||||||
|
restricted: signals.filter(s => RESTRICTED.includes(s.sensitivityLevel)).length,
|
||||||
|
needsReview: signals.filter(s => !s.isVerified).length,
|
||||||
|
avgTimeHorizonMonths:
|
||||||
|
signals.length > 0
|
||||||
|
? Math.round(signals.reduce((sum, s) => sum + (s.timeHorizonMonths ?? 0), 0) / signals.length)
|
||||||
|
: 0,
|
||||||
|
timeHorizonDistribution: {
|
||||||
|
short: signals.filter(s => (s.timeHorizonMonths ?? 0) <= 6).length,
|
||||||
|
medium: signals.filter(s => { const m = s.timeHorizonMonths ?? 0; return m > 6 && m <= 12 }).length,
|
||||||
|
long: signals.filter(s => (s.timeHorizonMonths ?? 0) > 12).length,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { MockupMatchProvider } from '../provider/MockupMatchProvider'
|
import { MockupMatchProvider } from '../provider/MockupMatchProvider'
|
||||||
|
import { MockupPropertyProvider } from '../provider/MockupPropertyProvider'
|
||||||
import type { MatchFilters } from '../provider/IMatchProvider'
|
import type { MatchFilters } from '../provider/IMatchProvider'
|
||||||
import type { Match } from '../domain/match'
|
import type { Match } from '../domain/match'
|
||||||
|
import type { StrongMatchItem } from '../domain/dashboard'
|
||||||
import type { ListResponse, ItemResponse } from './types'
|
import type { ListResponse, ItemResponse } from './types'
|
||||||
|
|
||||||
const provider = MockupMatchProvider
|
const provider = MockupMatchProvider
|
||||||
@@ -26,4 +28,32 @@ export const matchService = {
|
|||||||
const data = await provider.approve(id, reviewedBy)
|
const data = await provider.approve(id, reviewedBy)
|
||||||
return { data }
|
return { data }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getStrongMatches(minScore = 80): Promise<StrongMatchItem[]> {
|
||||||
|
const [matches, properties] = await Promise.all([
|
||||||
|
provider.getAll(),
|
||||||
|
MockupPropertyProvider.getAll(),
|
||||||
|
])
|
||||||
|
return matches
|
||||||
|
.filter(m => m.matchScore >= minScore)
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(m => {
|
||||||
|
const prop = properties.find(p => p.id === m.propertyId)
|
||||||
|
const topFactor = m.positiveFactors?.[0]
|
||||||
|
const firstAction = m.nextBestActions?.[0]
|
||||||
|
return {
|
||||||
|
matchId: m.id,
|
||||||
|
propertyId: m.propertyId,
|
||||||
|
propertyTitle: prop?.title ?? 'Unbekanntes Objekt',
|
||||||
|
propertyAddress: prop?.address
|
||||||
|
? `${prop.address.street} ${prop.address.houseNumber}, ${prop.address.city}`
|
||||||
|
: '–',
|
||||||
|
needSummary: m.needId,
|
||||||
|
matchScore: m.matchScore,
|
||||||
|
topReason: topFactor?.explanation ?? topFactor?.criterion ?? '–',
|
||||||
|
missingDataCount: m.missingData?.length ?? 0,
|
||||||
|
nextBestAction: firstAction?.label ?? '–',
|
||||||
|
} satisfies StrongMatchItem
|
||||||
|
})
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { ListResponse, ItemResponse } from './types'
|
|||||||
import type { Property } from '../domain/property'
|
import type { Property } from '../domain/property'
|
||||||
|
|
||||||
const provider = MockupPropertyProvider
|
const provider = MockupPropertyProvider
|
||||||
|
const ACTIVE_STATUSES: readonly string[] = ['AVAILABLE_NOW', 'AVAILABLE_SOON']
|
||||||
|
|
||||||
export const propertyService = {
|
export const propertyService = {
|
||||||
async getAll(filters?: PropertyFilters): Promise<ListResponse<Property>> {
|
async getAll(filters?: PropertyFilters): Promise<ListResponse<Property>> {
|
||||||
@@ -27,4 +28,12 @@ export const propertyService = {
|
|||||||
await provider.remove(id)
|
await provider.remove(id)
|
||||||
return { data: undefined }
|
return { data: undefined }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getDashboardPropertiesSummary(): Promise<{ total: number; active: number }> {
|
||||||
|
const data = await provider.getAll()
|
||||||
|
return {
|
||||||
|
total: data.length,
|
||||||
|
active: data.filter(p => ACTIVE_STATUSES.includes(p.availabilityStatus)).length,
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { MockupReviewProvider } from '../provider/MockupReviewProvider'
|
import { MockupReviewProvider } from '../provider/MockupReviewProvider'
|
||||||
import type { ReviewFilters } from '../provider/IReviewProvider'
|
import type { ReviewFilters } from '../provider/IReviewProvider'
|
||||||
import type { ReviewQueueItem } from '../domain/review'
|
import type { ReviewQueueItem } from '../domain/review'
|
||||||
|
import type { DashboardReviewTask } from '../domain/dashboard'
|
||||||
import type { ListResponse, ItemResponse } from './types'
|
import type { ListResponse, ItemResponse } from './types'
|
||||||
|
|
||||||
const provider = MockupReviewProvider
|
const provider = MockupReviewProvider
|
||||||
@@ -26,4 +27,20 @@ export const reviewService = {
|
|||||||
const data = await provider.assign(id, assignTo)
|
const data = await provider.assign(id, assignTo)
|
||||||
return { data }
|
return { data }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async createReviewTask(signalId: string): Promise<ItemResponse<{ taskId: string }>> {
|
||||||
|
const taskId = `rt-${signalId}-${Date.now()}`
|
||||||
|
return { data: { taskId } }
|
||||||
|
},
|
||||||
|
|
||||||
|
async getDashboardTasks(): Promise<DashboardReviewTask[]> {
|
||||||
|
const items = await provider.getQueue()
|
||||||
|
return items.slice(0, 8).map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
title: `Match ${r.matchId} · Score ${r.matchScore}`,
|
||||||
|
priority: r.priority,
|
||||||
|
status: r.status,
|
||||||
|
type: 'REVIEW',
|
||||||
|
}))
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user