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:
Benjamin Sutter
2026-05-15 16:31:54 +02:00
parent 46acca9e62
commit 7b7be0b436
12 changed files with 454 additions and 183 deletions
+24 -103
View File
@@ -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<DashboardData> {
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<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',
}))
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(),
}
+34
View File
@@ -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,
}
},
}
+21
View File
@@ -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<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,
},
}
},
}
+30
View File
@@ -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<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
})
},
}
+9
View File
@@ -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<ListResponse<Property>> {
@@ -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,
}
},
}
+17
View File
@@ -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<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',
}))
},
}