diff --git a/src/components/supply/FutureSignalWidget.tsx b/src/components/supply/FutureSignalWidget.tsx index 034b77c..3cf9daa 100644 --- a/src/components/supply/FutureSignalWidget.tsx +++ b/src/components/supply/FutureSignalWidget.tsx @@ -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 ( - - Marktsignale - + + + Marktsignale + + navigate('/supply/future-availability')} + > + Alle anzeigen + + - + - + + + + + + {/* Time horizon distribution */} + + Zeithorizont-Verteilung + + + {[ + { label: '0–6 Monate', count: dist.short }, + { label: '6–12 Monate', count: dist.medium }, + { label: '12–24 Monate', count: dist.long }, + ].map(({ label, count }) => ( + + + {label} + {count} + + + + ))} {summary.restricted > 0 && ( diff --git a/src/components/supply/KpiCard.tsx b/src/components/supply/KpiCard.tsx index 8161816..f90ecd6 100644 --- a/src/components/supply/KpiCard.tsx +++ b/src/components/supply/KpiCard.tsx @@ -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 = ( + + + {card.label} + + + {card.value} + + {card.trendLabel && ( + + )} + + ) + + const card_ = ( - - - {card.label} - - - {card.value} - - {card.trendLabel && ( - - )} - + {card.onClick ? ( + + {content} + + ) : ( + content + )} ) + + if (card.tooltip) { + return ( + + {card_} + + ) + } + + return card_ } diff --git a/src/components/supply/KpiGrid.tsx b/src/components/supply/KpiGrid.tsx index cfb5f78..9c9a1e2 100644 --- a/src/components/supply/KpiGrid.tsx +++ b/src/components/supply/KpiGrid.tsx @@ -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'), }, ] diff --git a/src/components/supply/StrongMatchMiniCard.tsx b/src/components/supply/StrongMatchMiniCard.tsx index d271379..30b6d64 100644 --- a/src/components/supply/StrongMatchMiniCard.tsx +++ b/src/components/supply/StrongMatchMiniCard.tsx @@ -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 ( - + {match.propertyTitle} @@ -31,13 +34,15 @@ export function StrongMatchMiniCard({ match }: StrongMatchMiniCardProps) { }} /> + {match.propertyAddress} {match.topReason} - + + {match.missingDataCount > 0 && ( )} - {match.nextBestAction !== '–' && ( - - )} + + + + + + diff --git a/src/domain/dashboard.ts b/src/domain/dashboard.ts index 2a8b38a..be478b7 100644 --- a/src/domain/dashboard.ts +++ b/src/domain/dashboard.ts @@ -5,6 +5,8 @@ export interface KpiCardData { trend?: 'up' | 'down' | 'neutral' trendLabel?: string accent?: string + tooltip?: string + onClick?: () => void } export interface StrongMatchItem { @@ -19,12 +21,19 @@ export interface StrongMatchItem { nextBestAction: string } +export interface TimeHorizonDistribution { + short: number // 0–6 months + medium: number // 6–12 months + long: number // 12–24 months +} + export interface FutureSignalSummary { total: number highConfidence: number restricted: number needsReview: number avgTimeHorizonMonths: number + timeHorizonDistribution: TimeHorizonDistribution } export interface DataQualitySummary { @@ -47,9 +56,9 @@ export interface DashboardData { activeProperties: number strongMatchCount: number avgDataQuality: number - futureSignals: FutureSignalSummary - dataQuality: DataQualitySummary - reviewTasks: DashboardReviewTask[] - strongMatches: StrongMatchItem[] + futureSignals: FutureSignalSummary | null + dataQuality: DataQualitySummary | null + reviewTasks: DashboardReviewTask[] | null + strongMatches: StrongMatchItem[] | null lastUpdated: string } diff --git a/src/pages/supply/SupplyDashboard.tsx b/src/pages/supply/SupplyDashboard.tsx index f996517..ed5cb7f 100644 --- a/src/pages/supply/SupplyDashboard.tsx +++ b/src/pages/supply/SupplyDashboard.tsx @@ -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 ( + + {label} konnte nicht geladen werden. + + ) +} + export default function SupplyDashboard() { - const { data, isLoading } = useSupplyDashboard() + const { data, isLoading, isError, refetch } = useSupplyDashboard() + const { currentUser } = useSessionStore() const navigate = useNavigate() - if (isLoading || !data) return + 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 + + if (isError) { + return ( + + + Dashboard konnte nicht geladen werden + + + Der Service ist vorübergehend nicht verfügbar. + + + + ) + } + + if (!data || data.totalProperties === 0) { + return ( + + Noch keine Objekte vorhanden + + Fügen Sie Ihr erstes Objekt hinzu oder laden Sie Demo-Daten. + + + + ) + } return ( - - - - navigate('/supply/match-center')} - /> - navigate('/supply/data-quality')} - /> - - - - navigate('/ops/review-queue')} - /> - + + {isOwnerViewer ? ( + + + + Aktive Objekte + + + {data.activeProperties} / {data.totalProperties} + + + + + Ø Datenqualität + + + {data.avgDataQuality}% + + + + ) : ( + + )} + + {/* REVIEWER: Review Queue first, then Matches */} + {isReviewer && ( + + {data.reviewTasks !== null ? ( + navigate('/ops/review-queue')} + /> + ) : ( + + )} + {canSeeMatches && + (data.strongMatches !== null ? ( + navigate('/supply/match-center')} + /> + ) : ( + + ))} + + )} + + {/* Default: Matches + Data Quality */} + {!isReviewer && canSeeMatches && ( + + {data.strongMatches !== null ? ( + navigate('/supply/match-center')} + /> + ) : ( + + )} + {data.dataQuality !== null ? ( + navigate('/supply/data-quality')} + /> + ) : ( + + )} + + )} + + {canSeeMatches && data.strongMatches?.length === 0 && ( + navigate('/demand/ai-search')}> + Bedarfsprofil erstellen + + } + > + Noch keine Matches vorhanden. Erstellen Sie ein Bedarfsprofil, um passende Objekte zu + finden. + + )} + + {!isOwnerViewer && ( + + {data.futureSignals !== null ? ( + + ) : ( + + )} + {canSeeOperations && !isReviewer ? ( + data.reviewTasks !== null ? ( + navigate('/ops/review-queue')} + /> + ) : ( + + ) + ) : !canSeeOperations && data.dataQuality !== null ? ( + navigate('/supply/data-quality')} + /> + ) : null} + + )} + ) diff --git a/src/services/dashboardService.ts b/src/services/dashboardService.ts index 469093e..d51df6a 100644 --- a/src/services/dashboardService.ts +++ b/src/services/dashboardService.ts @@ -1,113 +1,34 @@ -import { MockupPropertyProvider } from '../provider/MockupPropertyProvider' -import { MockupMatchProvider } from '../provider/MockupMatchProvider' -import { MockupFutureSignalProvider } from '../provider/MockupFutureSignalProvider' -import { MockupReviewProvider } from '../provider/MockupReviewProvider' -import type { - DashboardData, - 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) -} +import { propertyService } from './propertyService' +import { matchService } from './matchService' +import { futureSignalService } from './futureSignalService' +import { dataQualityService } from './dataQualityService' +import { reviewService } from './reviewService' +import type { DashboardData } from '../domain/dashboard' export const dashboardService = { async getDashboardData(): Promise { - const [properties, matches, signals, reviewItems] = await Promise.all([ - MockupPropertyProvider.getAll(), - MockupMatchProvider.getAll(), - MockupFutureSignalProvider.getAll(), - MockupReviewProvider.getQueue(), + const [propRes, matchRes, signalRes, qualityRes, reviewRes] = await Promise.allSettled([ + propertyService.getDashboardPropertiesSummary(), + matchService.getStrongMatches(), + futureSignalService.getSignalSummary(), + dataQualityService.getPortfolioQualitySummary(), + reviewService.getDashboardTasks(), ]) - const activeProperties = properties.filter(p => - isActiveStatus(p.availabilityStatus), - ) - - const strongMatches: StrongMatchItem[] = matches - .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>((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', - })) + const propSummary = propRes.status === 'fulfilled' ? propRes.value : null + 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 tasks = reviewRes.status === 'fulfilled' ? reviewRes.value : null return { - totalProperties: properties.length, - activeProperties: activeProperties.length, - strongMatchCount: matches.filter(m => m.matchScore >= 80).length, - avgDataQuality: avgQuality, - futureSignals: { - total: signals.length, - highConfidence: highConfidenceSignals.length, - 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, + totalProperties: propSummary?.total ?? 0, + activeProperties: propSummary?.active ?? 0, + strongMatchCount: strongMatches?.length ?? 0, + avgDataQuality: quality?.avgScore ?? 0, + futureSignals: signals, + dataQuality: quality, + reviewTasks: tasks, strongMatches, lastUpdated: new Date().toISOString(), } diff --git a/src/services/dataQualityService.ts b/src/services/dataQualityService.ts new file mode 100644 index 0000000..b135c7c --- /dev/null +++ b/src/services/dataQualityService.ts @@ -0,0 +1,34 @@ +import { MockupPropertyProvider } from '../provider/MockupPropertyProvider' +import type { DataQualitySummary } from '../domain/dashboard' + +export const dataQualityService = { + async getPortfolioQualitySummary(): Promise { + 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>((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, + } + }, +} diff --git a/src/services/futureSignalService.ts b/src/services/futureSignalService.ts index 40d6afb..da72755 100644 --- a/src/services/futureSignalService.ts +++ b/src/services/futureSignalService.ts @@ -1,6 +1,7 @@ import { MockupFutureSignalProvider } from '../provider/MockupFutureSignalProvider' import type { FutureSignalFilters } from '../provider/IFutureSignalProvider' import type { FutureSignal } from '../domain/futureSignal' +import type { FutureSignalSummary } from '../domain/dashboard' import type { ListResponse, ItemResponse } from './types' const provider = MockupFutureSignalProvider @@ -22,4 +23,24 @@ export const futureSignalService = { const data = await provider.verify(id, verifiedBy) return { data } }, + + async getSignalSummary(): Promise { + 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, + }, + } + }, } diff --git a/src/services/matchService.ts b/src/services/matchService.ts index 08427e1..b9e1973 100644 --- a/src/services/matchService.ts +++ b/src/services/matchService.ts @@ -1,6 +1,8 @@ import { MockupMatchProvider } from '../provider/MockupMatchProvider' +import { MockupPropertyProvider } from '../provider/MockupPropertyProvider' import type { MatchFilters } from '../provider/IMatchProvider' import type { Match } from '../domain/match' +import type { StrongMatchItem } from '../domain/dashboard' import type { ListResponse, ItemResponse } from './types' const provider = MockupMatchProvider @@ -26,4 +28,32 @@ export const matchService = { const data = await provider.approve(id, reviewedBy) return { data } }, + + async getStrongMatches(minScore = 80): Promise { + 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 + }) + }, } diff --git a/src/services/propertyService.ts b/src/services/propertyService.ts index 9a542b6..76da58d 100644 --- a/src/services/propertyService.ts +++ b/src/services/propertyService.ts @@ -5,6 +5,7 @@ import type { ListResponse, ItemResponse } from './types' import type { Property } from '../domain/property' const provider = MockupPropertyProvider +const ACTIVE_STATUSES: readonly string[] = ['AVAILABLE_NOW', 'AVAILABLE_SOON'] export const propertyService = { async getAll(filters?: PropertyFilters): Promise> { @@ -27,4 +28,12 @@ export const propertyService = { await provider.remove(id) 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, + } + }, } diff --git a/src/services/reviewService.ts b/src/services/reviewService.ts index 4b7734e..819bedd 100644 --- a/src/services/reviewService.ts +++ b/src/services/reviewService.ts @@ -1,6 +1,7 @@ import { MockupReviewProvider } from '../provider/MockupReviewProvider' import type { ReviewFilters } from '../provider/IReviewProvider' import type { ReviewQueueItem } from '../domain/review' +import type { DashboardReviewTask } from '../domain/dashboard' import type { ListResponse, ItemResponse } from './types' const provider = MockupReviewProvider @@ -26,4 +27,20 @@ export const reviewService = { const data = await provider.assign(id, assignTo) return { data } }, + + async createReviewTask(signalId: string): Promise> { + const taskId = `rt-${signalId}-${Date.now()}` + return { data: { taskId } } + }, + + async getDashboardTasks(): Promise { + 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', + })) + }, }