1d0cb8b154
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>
168 lines
8.0 KiB
TypeScript
168 lines
8.0 KiB
TypeScript
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 2–14 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> {
|
||
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,
|
||
}
|
||
},
|
||
|
||
getMissingCriticalFields,
|
||
getQualityWarnings,
|
||
getRecommendedActions,
|
||
calculatePropertyQuality,
|
||
}
|