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,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(),
|
||||
}
|
||||
|
||||
@@ -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 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,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}))
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user