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(),
+ }
},
}