diff --git a/src/components/supply/DashboardHeader.tsx b/src/components/supply/DashboardHeader.tsx new file mode 100644 index 0000000..f7c0a9e --- /dev/null +++ b/src/components/supply/DashboardHeader.tsx @@ -0,0 +1,48 @@ +import { Box, Button, Chip, Typography } from '@mui/material' +import { useNavigate } from 'react-router' + +interface DashboardHeaderProps { + orgName?: string + lastUpdated?: string +} + +function formatLastUpdated(iso: string): string { + try { + return new Intl.DateTimeFormat('de-CH', { + dateStyle: 'short', + timeStyle: 'short', + }).format(new Date(iso)) + } catch { + return iso + } +} + +export function DashboardHeader({ orgName = 'Demo Organisation', lastUpdated }: DashboardHeaderProps) { + const navigate = useNavigate() + + return ( + + + + + {orgName} + + + + {lastUpdated && ( + + Zuletzt aktualisiert: {formatLastUpdated(lastUpdated)} + + )} + + + + + + + ) +} diff --git a/src/components/supply/DashboardSkeleton.tsx b/src/components/supply/DashboardSkeleton.tsx new file mode 100644 index 0000000..b9d45a3 --- /dev/null +++ b/src/components/supply/DashboardSkeleton.tsx @@ -0,0 +1,67 @@ +import { Box, Card, CardContent, Skeleton } from '@mui/material' + +function SkeletonCard() { + return ( + + + + + + + ) +} + +function SkeletonLargeCard() { + return ( + + + + + + + + ) +} + +export function DashboardSkeleton() { + return ( + + {/* Header skeleton */} + + + + + + {/* KPI grid skeleton */} + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + + {/* Row 2 */} + + + + + + {/* Row 3 */} + + + + + + {/* Quick actions skeleton */} + + + + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + + + + ) +} diff --git a/src/components/supply/DataQualityWidget.tsx b/src/components/supply/DataQualityWidget.tsx new file mode 100644 index 0000000..04bdc38 --- /dev/null +++ b/src/components/supply/DataQualityWidget.tsx @@ -0,0 +1,81 @@ +import { Box, Button, Card, CardContent, Chip, LinearProgress, Typography } from '@mui/material' +import type { DataQualitySummary } from '../../domain/dashboard' + +interface DataQualityWidgetProps { + summary: DataQualitySummary + onNavigate: () => void +} + +function qualityColor(score: number): 'success' | 'warning' | 'error' { + if (score >= 80) return 'success' + if (score >= 60) return 'warning' + return 'error' +} + +export function DataQualityWidget({ summary, onNavigate }: DataQualityWidgetProps) { + const color = qualityColor(summary.avgScore) + + return ( + + + + + Datenqualität + + + + + + + + Ø Score + + + {summary.avgScore}% + + + + + + + + 0 ? 'error.main' : 'text.primary' }}> + {summary.critical} + + + Kritische Objekte + + + + + {summary.propertiesWithMissingCritical} + + + Fehlende Pflichtfelder + + + + + {summary.topMissingFields.length > 0 && ( + + + Häufig fehlende Felder: + + + {summary.topMissingFields.map(field => ( + + ))} + + + )} + + + ) +} diff --git a/src/components/supply/FutureSignalWidget.tsx b/src/components/supply/FutureSignalWidget.tsx new file mode 100644 index 0000000..034b77c --- /dev/null +++ b/src/components/supply/FutureSignalWidget.tsx @@ -0,0 +1,72 @@ +import { Box, Card, CardContent, Divider, Typography } from '@mui/material' +import type { FutureSignalSummary } from '../../domain/dashboard' + +interface FutureSignalWidgetProps { + summary: FutureSignalSummary +} + +interface StatItemProps { + label: string + value: number | string + highlight?: boolean +} + +function StatItem({ label, value, highlight }: StatItemProps) { + return ( + + + {value} + + + {label} + + + ) +} + +export function FutureSignalWidget({ summary }: FutureSignalWidgetProps) { + return ( + + + + Marktsignale + + + + + + + + + + {summary.restricted > 0 && ( + <> + + + {summary.restricted} vertrauliche Signale — nur für berechtigte Nutzer sichtbar. + + + )} + + + + Marktsignale basieren auf AI-Analyse öffentlicher und interner Daten. Es handelt + sich um probabilistische Einschätzungen, keine bestätigten Objekte. + + + + ) +} diff --git a/src/components/supply/KpiCard.tsx b/src/components/supply/KpiCard.tsx new file mode 100644 index 0000000..8161816 --- /dev/null +++ b/src/components/supply/KpiCard.tsx @@ -0,0 +1,40 @@ +import { Card, CardContent, Chip, Typography } from '@mui/material' +import type { KpiCardData } from '../../domain/dashboard' + +interface KpiCardProps { + card: KpiCardData +} + +export function KpiCard({ card }: KpiCardProps) { + const trendColor = + card.trend === 'up' ? 'success' : card.trend === 'down' ? 'error' : 'default' + + return ( + + + + {card.label} + + + {card.value} + + {card.trendLabel && ( + + )} + + + ) +} diff --git a/src/components/supply/KpiGrid.tsx b/src/components/supply/KpiGrid.tsx new file mode 100644 index 0000000..cfb5f78 --- /dev/null +++ b/src/components/supply/KpiGrid.tsx @@ -0,0 +1,69 @@ +import { Box } from '@mui/material' +import type { DashboardData, KpiCardData } from '../../domain/dashboard' +import { KpiCard } from './KpiCard' + +interface KpiGridProps { + data: DashboardData +} + +export function KpiGrid({ data }: KpiGridProps) { + const pendingCount = data.reviewTasks.filter(t => t.status === 'PENDING').length + + const qualityColor = + data.avgDataQuality >= 80 + ? '#1a7a4a' + : data.avgDataQuality >= 60 + ? '#d97706' + : '#c0392b' + + const cards: KpiCardData[] = [ + { + id: 'active-properties', + label: 'Aktive Objekte', + value: `${data.activeProperties} / ${data.totalProperties}`, + }, + { + id: 'strong-matches', + label: 'Starke Matches', + value: data.strongMatchCount, + accent: '#1a7a4a', + }, + { + id: 'avg-quality', + label: 'Ø Datenqualität', + value: `${data.avgDataQuality}%`, + accent: qualityColor, + }, + { + id: 'market-signals', + label: 'Marktsignale', + value: data.futureSignals.total, + }, + { + id: 'review-pending', + label: 'Review ausstehend', + value: pendingCount, + accent: pendingCount > 0 ? '#d97706' : undefined, + }, + { + id: 'critical-gaps', + label: 'Kritische Datenlücken', + value: data.dataQuality.critical, + accent: data.dataQuality.critical > 0 ? '#c0392b' : undefined, + }, + ] + + return ( + + {cards.map(card => ( + + ))} + + ) +} diff --git a/src/components/supply/QuickActionPanel.tsx b/src/components/supply/QuickActionPanel.tsx new file mode 100644 index 0000000..944cc85 --- /dev/null +++ b/src/components/supply/QuickActionPanel.tsx @@ -0,0 +1,43 @@ +import { Box, Button, Card, CardContent, Typography } from '@mui/material' +import { useNavigate } from 'react-router' + +const ACTIONS = [ + { label: 'Objekte verwalten', path: '/supply/properties' }, + { label: 'Match Center', path: '/supply/match-center' }, + { label: 'Marktchancen', path: '/supply/future-availability' }, + { label: 'Datenqualität', path: '/supply/data-quality' }, + { label: 'Review Queue', path: '/ops/review-queue' }, + { label: 'Market Intelligence', path: '/ops/market-intelligence' }, +] as const + +export function QuickActionPanel() { + const navigate = useNavigate() + + return ( + + + + Schnellzugriff + + + {ACTIONS.map(action => ( + + ))} + + + + ) +} diff --git a/src/components/supply/ReviewTaskWidget.tsx b/src/components/supply/ReviewTaskWidget.tsx new file mode 100644 index 0000000..7a66e19 --- /dev/null +++ b/src/components/supply/ReviewTaskWidget.tsx @@ -0,0 +1,95 @@ +import { Box, Button, Card, CardContent, Chip, Typography } from '@mui/material' +import type { DashboardReviewTask } from '../../domain/dashboard' + +interface ReviewTaskWidgetProps { + tasks: DashboardReviewTask[] + onNavigate: () => void +} + +const PRIORITY_LABELS: Record = { + HIGH: 'Hoch', + MEDIUM: 'Mittel', + LOW: 'Niedrig', +} + +const STATUS_LABELS: Record = { + PENDING: 'Ausstehend', + IN_REVIEW: 'In Bearbeitung', + COMPLETED: 'Abgeschlossen', +} + +function priorityColor(priority: string): 'error' | 'warning' | 'default' { + if (priority === 'HIGH') return 'error' + if (priority === 'MEDIUM') return 'warning' + return 'default' +} + +function statusColor(status: string): 'warning' | 'info' | 'success' | 'default' { + if (status === 'PENDING') return 'warning' + if (status === 'IN_REVIEW') return 'info' + if (status === 'COMPLETED') return 'success' + return 'default' +} + +const PRIORITY_ORDER: Record = { HIGH: 0, MEDIUM: 1, LOW: 2 } + +export function ReviewTaskWidget({ tasks, onNavigate }: ReviewTaskWidgetProps) { + const sorted = [...tasks].sort( + (a, b) => (PRIORITY_ORDER[a.priority] ?? 9) - (PRIORITY_ORDER[b.priority] ?? 9), + ) + + return ( + + + + + Review Queue + + + + + {sorted.length === 0 ? ( + + Keine offenen Aufgaben. + + ) : ( + + {sorted.map(task => ( + + + + {task.title} + + + + ))} + + )} + + + ) +} diff --git a/src/components/supply/StrongMatchMiniCard.tsx b/src/components/supply/StrongMatchMiniCard.tsx new file mode 100644 index 0000000..d271379 --- /dev/null +++ b/src/components/supply/StrongMatchMiniCard.tsx @@ -0,0 +1,59 @@ +import { Box, Card, CardContent, Chip, Typography } from '@mui/material' +import type { StrongMatchItem } from '../../domain/dashboard' + +interface StrongMatchMiniCardProps { + match: StrongMatchItem +} + +function scoreColor(score: number): string { + if (score >= 80) return '#1a7a4a' + if (score >= 60) return '#d97706' + return '#c0392b' +} + +export function StrongMatchMiniCard({ match }: StrongMatchMiniCardProps) { + return ( + + + + + {match.propertyTitle} + + + + + {match.propertyAddress} + + + {match.topReason} + + + {match.missingDataCount > 0 && ( + + )} + {match.nextBestAction !== '–' && ( + + )} + + + + ) +} diff --git a/src/components/supply/StrongMatchOverview.tsx b/src/components/supply/StrongMatchOverview.tsx new file mode 100644 index 0000000..af529ff --- /dev/null +++ b/src/components/supply/StrongMatchOverview.tsx @@ -0,0 +1,35 @@ +import { Box, Button, Card, CardContent, Typography } from '@mui/material' +import type { StrongMatchItem } from '../../domain/dashboard' +import { StrongMatchMiniCard } from './StrongMatchMiniCard' + +interface StrongMatchOverviewProps { + matches: StrongMatchItem[] + onNavigate: () => void +} + +export function StrongMatchOverview({ matches, onNavigate }: StrongMatchOverviewProps) { + return ( + + + + + Starke Matches + + + + + {matches.length === 0 ? ( + + Keine starken Matches vorhanden. + + ) : ( + matches.slice(0, 5).map(m => ( + + )) + )} + + + ) +} diff --git a/src/components/supply/index.ts b/src/components/supply/index.ts new file mode 100644 index 0000000..d42ad2d --- /dev/null +++ b/src/components/supply/index.ts @@ -0,0 +1,10 @@ +export { KpiCard } from './KpiCard' +export { KpiGrid } from './KpiGrid' +export { StrongMatchMiniCard } from './StrongMatchMiniCard' +export { StrongMatchOverview } from './StrongMatchOverview' +export { DataQualityWidget } from './DataQualityWidget' +export { FutureSignalWidget } from './FutureSignalWidget' +export { ReviewTaskWidget } from './ReviewTaskWidget' +export { QuickActionPanel } from './QuickActionPanel' +export { DashboardSkeleton } from './DashboardSkeleton' +export { DashboardHeader } from './DashboardHeader' diff --git a/src/domain/dashboard.ts b/src/domain/dashboard.ts new file mode 100644 index 0000000..2a8b38a --- /dev/null +++ b/src/domain/dashboard.ts @@ -0,0 +1,55 @@ +export interface KpiCardData { + id: string + label: string + value: number | string + trend?: 'up' | 'down' | 'neutral' + trendLabel?: string + accent?: string +} + +export interface StrongMatchItem { + matchId: string + propertyId: string + propertyTitle: string + propertyAddress: string + needSummary: string + matchScore: number + topReason: string + missingDataCount: number + nextBestAction: string +} + +export interface FutureSignalSummary { + total: number + highConfidence: number + restricted: number + needsReview: number + avgTimeHorizonMonths: number +} + +export interface DataQualitySummary { + avgScore: number + critical: number + propertiesWithMissingCritical: number + topMissingFields: string[] +} + +export interface DashboardReviewTask { + id: string + title: string + priority: 'HIGH' | 'MEDIUM' | 'LOW' + status: string + type: string +} + +export interface DashboardData { + totalProperties: number + activeProperties: number + strongMatchCount: number + avgDataQuality: number + futureSignals: FutureSignalSummary + dataQuality: DataQualitySummary + reviewTasks: DashboardReviewTask[] + strongMatches: StrongMatchItem[] + lastUpdated: string +} diff --git a/src/hooks/useSupplyDashboard.ts b/src/hooks/useSupplyDashboard.ts new file mode 100644 index 0000000..9d10620 --- /dev/null +++ b/src/hooks/useSupplyDashboard.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query' +import { dashboardService } from '../services/dashboardService' +import type { DashboardData } from '../domain/dashboard' + +export function useSupplyDashboard() { + return useQuery({ + queryKey: ['supply', 'dashboard'], + queryFn: () => dashboardService.getDashboardData(), + staleTime: 30_000, + }) +} diff --git a/src/pages/supply/SupplyDashboard.tsx b/src/pages/supply/SupplyDashboard.tsx index 750df2f..f996517 100644 --- a/src/pages/supply/SupplyDashboard.tsx +++ b/src/pages/supply/SupplyDashboard.tsx @@ -1,283 +1,48 @@ -import { Box, Card, Chip, LinearProgress, Table, TableBody, TableCell, TableHead, TableRow, Tooltip, Typography } from '@mui/material' -import { useQuery } from '@tanstack/react-query' -import { AlertTriangle, BarChart2, Building2, Target } from 'lucide-react' -import { SectionContainer, LoadingPage, ErrorState } from '../../components/ui' -import { propertyService } from '../../services/propertyService' -import { matchService } from '../../services/matchService' -import { governanceService } from '../../services/governanceService' -import { ResultType, MatchStrength } from '../../domain/enums' -import type { ActivityEventType } from '../../services/governanceService' - -function getMatchStrengthColor(strength: MatchStrength): 'success' | 'warning' | 'error' { - if (strength === MatchStrength.STRONG) return 'success' - if (strength === MatchStrength.MODERATE) return 'warning' - return 'error' -} - -function getMatchStrengthLabel(strength: MatchStrength): string { - if (strength === MatchStrength.STRONG) return 'Stark' - if (strength === MatchStrength.MODERATE) return 'Mittel' - return 'Schwach' -} - -function getEventDescription(type: ActivityEventType): string { - switch (type) { - case 'PROPERTY_CREATED': return 'hat ein Objekt erstellt' - case 'PROPERTY_UPDATED': return 'hat ein Objekt aktualisiert' - case 'MATCH_APPROVED': return 'hat einen Match genehmigt' - case 'MATCH_REJECTED': return 'hat einen Match abgelehnt' - case 'SIGNAL_VERIFIED': return 'hat ein Signal verifiziert' - case 'NEED_CREATED': return 'hat einen Bedarf erstellt' - case 'REVIEW_REQUESTED': return 'hat eine Überprüfung angefordert' - } -} - -function getEventColor(type: ActivityEventType): string { - switch (type) { - case 'PROPERTY_CREATED': return '#1e3a5f' - case 'PROPERTY_UPDATED': return '#1e3a5f' - case 'MATCH_APPROVED': return '#1a7a4a' - case 'MATCH_REJECTED': return '#c0392b' - case 'SIGNAL_VERIFIED': return '#7c3aed' - case 'NEED_CREATED': return '#d97706' - case 'REVIEW_REQUESTED': return '#d97706' - } -} - -function formatTimeAgo(dateStr: string): string { - const diff = Date.now() - new Date(dateStr).getTime() - const hours = Math.floor(diff / 3600000) - const days = Math.floor(hours / 24) - if (hours < 1) return 'vor weniger als 1 Stunde' - if (hours < 24) return `vor ${hours} Stunde${hours > 1 ? 'n' : ''}` - return `vor ${days} Tag${days > 1 ? 'en' : ''}` -} +import { Box } from '@mui/material' +import { useNavigate } from 'react-router' +import { PageHeader } from '../../components/layout' +import { useSupplyDashboard } from '../../hooks/useSupplyDashboard' +import { + KpiGrid, + StrongMatchOverview, + DataQualityWidget, + FutureSignalWidget, + ReviewTaskWidget, + QuickActionPanel, + DashboardSkeleton, +} from '../../components/supply' export default function SupplyDashboard() { - const { data: propResp, isLoading: propLoading, error: propError } = useQuery({ - queryKey: ['properties'], - queryFn: () => propertyService.getAll(), - }) - const { data: matchResp, isLoading: matchLoading, error: matchError } = useQuery({ - queryKey: ['matches'], - queryFn: () => matchService.getAll(), - }) - const { data: activityResp, isLoading: activityLoading, error: activityError } = useQuery({ - queryKey: ['activity', 'org-wincasa'], - queryFn: () => governanceService.getActivityLog('org-wincasa'), - }) + const { data, isLoading } = useSupplyDashboard() + const navigate = useNavigate() - if (propLoading || matchLoading || activityLoading) return - if (propError || matchError || activityError) return - - const properties = propResp?.data ?? [] - const matches = matchResp?.data ?? [] - const activities = activityResp?.data ?? [] - - const avgQuality = properties.length - ? properties.reduce((sum, p) => sum + p.dataQuality.score, 0) / properties.length - : 0 - const pendingReview = properties.filter(p => p.dataQuality.missingCriticalFields.length > 0).length - - const avgQualityColor = avgQuality >= 0.8 ? 'success.main' : avgQuality >= 0.6 ? 'warning.main' : 'error.main' - - const verifiedCount = properties.filter(p => p.resultType === ResultType.VERIFIED_PORTFOLIO).length - const marketCount = properties.filter(p => p.resultType === ResultType.EXTERNAL_MARKET).length - const futureCount = properties.filter(p => p.resultType === ResultType.FUTURE_AVAILABILITY).length - - const topMatches = [...matches].sort((a, b) => b.matchScore - a.matchScore).slice(0, 3) + if (isLoading || !data) return return ( - - {/* Page Header */} - - Supply Dashboard - Portfolioübersicht und aktuelle Kennzahlen + + + + + navigate('/supply/match-center')} + /> + navigate('/supply/data-quality')} + /> - - {/* Content */} - - - {/* Section 1 - KPI Cards */} - - {/* Objekte */} - - - - Objekte - - - {properties.length} - Gesamtportfolio - - - - {/* Aktive Matches */} - - - - Aktive Matches - - - {matches.length} - KI-generierte Matches - - - - {/* Ø Datenqualität */} - - - - Ø Datenqualität - = 0.8 ? '#1a7a4a' : avgQuality >= 0.6 ? '#d97706' : '#c0392b'} /> - - - {Math.round(avgQuality * 100)}% - - Durchschnittlicher Score - - - - {/* Prüfungen ausstehend */} - - - - Prüfungen ausstehend - - - 0 ? 'warning.main' : 'text.primary' }}> - {pendingReview} - - Kritische Felder fehlen - - - - - {/* Section 2 - Portfolio Overview */} - - - {verifiedCount} - Verified Portfolio - - - {marketCount} - Marktinserate - - - {futureCount} - Zukunftssignale - - - - {/* Section 3 - Recent Matches */} - - - - - - Objekt - Unternehmen - Score - Stärke - Aktion - - - - {topMatches.map(match => { - const property = properties.find(p => p.id === match.propertyId) - return ( - - - {property?.title ?? match.propertyId} - - - {match.needId} - - - - {match.matchScore} - - - - - - - - - - - - - - - - - ) - })} - -
-
-
- - {/* Section 4 - Activity Log */} - - - - {activities.slice(0, 5).map(event => ( - - - - - - - {event.performedBy} {getEventDescription(event.type)} - - {event.notes && ( - {event.notes} - )} - - - {formatTimeAgo(event.createdAt)} - - - ))} - - - - + + + navigate('/ops/review-queue')} + /> +
) } diff --git a/src/services/dashboardService.ts b/src/services/dashboardService.ts index 33a3f81..469093e 100644 --- a/src/services/dashboardService.ts +++ b/src/services/dashboardService.ts @@ -1,12 +1,115 @@ -import { MockupDashboardProvider } from '../provider/MockupDashboardProvider' -import type { DashboardStats } from '../provider/IDashboardProvider' -import type { ItemResponse } from './types' +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 provider = MockupDashboardProvider +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 = { - async getStats(organizationId?: string): Promise> { - const data = await provider.getStats(organizationId) - return { data } + async getDashboardData(): Promise { + const [properties, matches, signals, reviewItems] = await Promise.all([ + MockupPropertyProvider.getAll(), + MockupMatchProvider.getAll(), + MockupFutureSignalProvider.getAll(), + MockupReviewProvider.getQueue(), + ]) + + 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', + })) + + 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, + strongMatches, + lastUpdated: new Date().toISOString(), + } }, }