feat: F006 supply dashboard — KPI grid, strong matches, data quality, future signals, review tasks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<ItemResponse<DashboardStats>> {
|
||||
const data = await provider.getStats(organizationId)
|
||||
return { data }
|
||||
async getDashboardData(): Promise<DashboardData> {
|
||||
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<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',
|
||||
}))
|
||||
|
||||
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(),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user