feat: F017 data quality system — trust layer across all decision surfaces

New components: DataQualityBadge, DataQualityPanel, DataQualityProgress,
FreshnessIndicator, CriticalFieldWarning, MissingDataList, ProvenancePanel
Service: calculatePropertyQuality, getMissingCriticalFields,
getQualityWarnings, getRecommendedActions with field→action mapping
PropertyDetailView: tab 5/6 now use DataQualityPanel + ProvenancePanel
Datenpflege page: DataQualityBadge, FreshnessIndicator, filter by level,
recommended action column showing highest-priority next step per property

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-17 12:51:35 +02:00
parent e0fe238c26
commit 1d0cb8b154
11 changed files with 846 additions and 293 deletions
+133
View File
@@ -1,5 +1,133 @@
import { MockupPropertyProvider } from '../provider/MockupPropertyProvider'
import { FreshnessStatus } from '../domain/enums'
import type { DataQualitySummary } from '../domain/dashboard'
import type { DataQuality, Property } from '../domain/property'
export type RecommendedAction = {
id: string
label: string
detail: string
priority: 'HIGH' | 'MEDIUM' | 'LOW'
field?: string
}
// ── Field → Action map ────────────────────────────────────────────────────────
const FIELD_ACTION_MAP: Record<string, Omit<RecommendedAction, 'id'>> = {
'Mietpreis/m²': { label: 'Mietpreis ergänzen', detail: 'Fehlender Mietpreis schließt Objekt aus Budget-Matches aus', priority: 'HIGH', field: 'Mietpreis/m²' },
'Fläche m²': { label: 'Fläche bestätigen', detail: 'Fläche ist Hard-Kriterium für alle Matchings', priority: 'HIGH', field: 'Fläche m²' },
'Verfügbarkeit': { label: 'Verfügbarkeit bestätigen', detail: 'Timing ist entscheidend für Nachfrager mit Deadlines', priority: 'HIGH', field: 'Verfügbarkeit' },
'Adresse': { label: 'Adresse vervollständigen', detail: 'Für Standortbewertung und Kartenansicht notwendig', priority: 'HIGH', field: 'Adresse' },
'Beschreibung': { label: 'Beschreibung hinzufügen', detail: 'Verbesserter Kontext erhöht Nachfrager-Vertrauen', priority: 'MEDIUM', field: 'Beschreibung' },
'Soft Factors': { label: 'Passantenfrequenz & ESG', detail: 'Soft Factors verbessern Match-Scoring erheblich', priority: 'MEDIUM', field: 'Soft Factors' },
'Ausbaustandard': { label: 'Ausbaustandard angeben', detail: 'SHELL/BASIC/FULL/PREMIUM beeinflusst Eignung stark', priority: 'MEDIUM', field: 'Ausbaustandard' },
'Bilder': { label: 'Bilder hochladen', detail: 'Objektfotos steigern Anfragerate deutlich', priority: 'MEDIUM', field: 'Bilder' },
'Jahresmiete (CHF)': { label: 'Jahresmiete angeben', detail: 'Ergänzt Mietpreis/m² für Budgetvergleiche', priority: 'LOW', field: 'Jahresmiete (CHF)' },
'Expansionspotenzial': { label: 'Erweiterungsfläche angeben', detail: 'Wichtig für wachsende Unternehmen', priority: 'LOW', field: 'Expansionspotenzial' },
}
const FRESHNESS_ACTIONS: Record<string, RecommendedAction> = {
[FreshnessStatus.OUTDATED]: {
id: 'review_source',
label: 'Quelle überprüfen',
detail: 'Daten sind älter als 14 Tage — Verfügbarkeit könnte sich geändert haben',
priority: 'HIGH',
},
[FreshnessStatus.STALE]: {
id: 'update_data',
label: 'Daten aktualisieren',
detail: 'Daten sind 214 Tage alt — Aktualitätsscore reduziert',
priority: 'MEDIUM',
},
}
// ── Core Checks ───────────────────────────────────────────────────────────────
const CRITICAL_CHECKS: Array<{ field: string; present: (p: Property) => boolean }> = [
{ field: 'Mietpreis/m²', present: p => p.rentPricePerSqm > 0 },
{ field: 'Fläche m²', present: p => p.areaSqm > 0 },
{ field: 'Verfügbarkeit', present: p => !!p.availabilityDate },
{ field: 'Adresse', present: p => !!p.address?.street && !!p.address?.city },
]
const OPTIONAL_CHECKS: Array<{ field: string; present: (p: Property) => boolean }> = [
{ field: 'Beschreibung', present: p => !!p.description && p.description.length > 20 },
{ field: 'Soft Factors', present: p => !!(p.softFactors?.prestigeScore || p.softFactors?.footfallScore || p.softFactors?.commuterAccessScore) },
{ field: 'Ausbaustandard', present: p => !!p.hardFacts?.fitOut },
{ field: 'Bilder', present: p => (p.images?.length ?? 0) > 0 },
{ field: 'Jahresmiete (CHF)', present: p => !!p.rentChfSqmYear },
{ field: 'Expansionspotenzial', present: p => !!(p.expansionPotentialSqm || p.hardFacts) },
]
// ── Public API ────────────────────────────────────────────────────────────────
export function getMissingCriticalFields(property: Property): string[] {
const fromData = property.dataQuality?.missingCriticalFields ?? []
if (fromData.length > 0) return fromData
return CRITICAL_CHECKS.filter(c => !c.present(property)).map(c => c.field)
}
export function getQualityWarnings(property: Property): string[] {
return property.dataQuality?.warnings ?? []
}
export function getRecommendedActions(quality: DataQuality, freshness?: string): RecommendedAction[] {
const actions: RecommendedAction[] = []
for (const field of quality.missingCriticalFields) {
const def = FIELD_ACTION_MAP[field]
if (def) actions.push({ id: `fill_${field}`, ...def })
}
const fn = freshness ?? quality.freshness
if (fn && fn !== FreshnessStatus.FRESH) {
const freshnessAction = FRESHNESS_ACTIONS[fn]
if (freshnessAction) actions.push(freshnessAction)
}
for (const field of quality.missingOptionalFields) {
const def = FIELD_ACTION_MAP[field]
if (def) actions.push({ id: `fill_opt_${field}`, ...def })
}
return actions
}
export function calculatePropertyQuality(property: Property): DataQuality {
if (property.dataQuality?.qualityLevel) return property.dataQuality
const missingCritical = CRITICAL_CHECKS.filter(c => !c.present(property)).map(c => c.field)
const missingOptional = OPTIONAL_CHECKS.filter(c => !c.present(property)).map(c => c.field)
const completeness = 1 - (missingCritical.length * 0.15 + missingOptional.length * 0.05)
const confidence = property.confidenceScore ?? 0.5
const freshnessVal = property.dataQuality?.freshness ?? FreshnessStatus.OUTDATED
const freshnessFactor = freshnessVal === FreshnessStatus.FRESH ? 1 : freshnessVal === FreshnessStatus.STALE ? 0.7 : 0.4
const score = Math.min(1, Math.max(0, completeness * 0.5 + confidence * 0.3 + freshnessFactor * 0.2))
const warnings: string[] = []
if (confidence < 0.5) warnings.push('Niedrige Daten-Vertrauensscore')
if (freshnessVal === FreshnessStatus.OUTDATED) warnings.push('Daten sind veraltet (>14 Tage)')
if (missingCritical.length > 0) warnings.push(`${missingCritical.length} Pflichtfeld(er) fehlen`)
const qualityLevel = missingCritical.length > 0
? 'INCOMPLETE'
: score >= 0.8 ? 'HIGH' : score >= 0.6 ? 'MEDIUM' : 'LOW'
return {
score,
qualityLevel,
missingCriticalFields: missingCritical,
missingOptionalFields: missingOptional,
lastVerifiedAt: property.dataQuality?.lastVerifiedAt,
freshness: freshnessVal,
warnings,
}
}
// ── Portfolio summary (existing) ──────────────────────────────────────────────
export const dataQualityService = {
async getPortfolioQualitySummary(): Promise<DataQualitySummary> {
@@ -31,4 +159,9 @@ export const dataQualityService = {
topMissingFields,
}
},
getMissingCriticalFields,
getQualityWarnings,
getRecommendedActions,
calculatePropertyQuality,
}