From 307b45080714d624916e2ba1b94614c97e368a78 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Sat, 16 May 2026 13:18:58 +0200 Subject: [PATCH] feat: F010 unified result feed Replace monolithic Results.tsx with decomposed component architecture: - 8 new components in src/components/results/ (ResultTypeBadge, ResultConfidenceSummary, FeedEmptyState, FeedSkeleton, ResultFeedHeader, ResultFilterBar, UnifiedResultCard, UnifiedResultFeed) - Reuses existing useUnifiedResults hook and compareStore - FUTURE_AVAILABILITY cards always display non-dismissable disclaimer - Filter by result type + sort by score/area/rent Co-Authored-By: Claude Sonnet 4.6 --- src/components/results/FeedEmptyState.tsx | 14 + src/components/results/FeedSkeleton.tsx | 22 + .../results/ResultConfidenceSummary.tsx | 33 ++ src/components/results/ResultFeedHeader.tsx | 21 + src/components/results/ResultFilterBar.tsx | 71 +++ src/components/results/ResultTypeBadge.tsx | 19 + src/components/results/UnifiedResultCard.tsx | 159 +++++++ src/components/results/UnifiedResultFeed.tsx | 29 ++ src/components/results/index.ts | 8 + src/pages/demand/Results.tsx | 420 +++--------------- 10 files changed, 427 insertions(+), 369 deletions(-) create mode 100644 src/components/results/FeedEmptyState.tsx create mode 100644 src/components/results/FeedSkeleton.tsx create mode 100644 src/components/results/ResultConfidenceSummary.tsx create mode 100644 src/components/results/ResultFeedHeader.tsx create mode 100644 src/components/results/ResultFilterBar.tsx create mode 100644 src/components/results/ResultTypeBadge.tsx create mode 100644 src/components/results/UnifiedResultCard.tsx create mode 100644 src/components/results/UnifiedResultFeed.tsx create mode 100644 src/components/results/index.ts diff --git a/src/components/results/FeedEmptyState.tsx b/src/components/results/FeedEmptyState.tsx new file mode 100644 index 0000000..e3da2a5 --- /dev/null +++ b/src/components/results/FeedEmptyState.tsx @@ -0,0 +1,14 @@ +import { EmptyState } from '../ui' + +export function FeedEmptyState({ filtered }: { filtered?: boolean }) { + return ( + + ) +} diff --git a/src/components/results/FeedSkeleton.tsx b/src/components/results/FeedSkeleton.tsx new file mode 100644 index 0000000..bfd8f95 --- /dev/null +++ b/src/components/results/FeedSkeleton.tsx @@ -0,0 +1,22 @@ +import { Card, Skeleton, Stack } from '@mui/material' + +export function FeedSkeleton() { + return ( + <> + {[1, 2, 3].map(i => ( + + + + + + + + + + + + + ))} + + ) +} diff --git a/src/components/results/ResultConfidenceSummary.tsx b/src/components/results/ResultConfidenceSummary.tsx new file mode 100644 index 0000000..1ab9577 --- /dev/null +++ b/src/components/results/ResultConfidenceSummary.tsx @@ -0,0 +1,33 @@ +import { Box, LinearProgress, Stack, Typography } from '@mui/material' + +interface Props { + confidenceLevel: number + dataQualityScore?: number +} + +export function ResultConfidenceSummary({ confidenceLevel, dataQualityScore }: Props) { + const confColor = confidenceLevel >= 0.8 ? '#1a7a4a' : confidenceLevel >= 0.6 ? '#d97706' : '#c0392b' + return ( + + + Konfidenz + {Math.round(confidenceLevel * 100)}% + + {dataQualityScore !== undefined && ( + + Datenqualität + + + + + {Math.round(dataQualityScore * 100)}% + + + )} + + ) +} diff --git a/src/components/results/ResultFeedHeader.tsx b/src/components/results/ResultFeedHeader.tsx new file mode 100644 index 0000000..daae81b --- /dev/null +++ b/src/components/results/ResultFeedHeader.tsx @@ -0,0 +1,21 @@ +import { Box, Typography } from '@mui/material' + +interface Props { + total: number + verifiedCount: number + externalCount: number + futureCount: number +} + +export function ResultFeedHeader({ total, verifiedCount, externalCount, futureCount }: Props) { + return ( + + + {total} Treffer gefunden + + + {verifiedCount} Verified · {externalCount} Extern · {futureCount} Signale + + + ) +} diff --git a/src/components/results/ResultFilterBar.tsx b/src/components/results/ResultFilterBar.tsx new file mode 100644 index 0000000..9e40933 --- /dev/null +++ b/src/components/results/ResultFilterBar.tsx @@ -0,0 +1,71 @@ +import { Box, Card, Chip, Stack, Typography } from '@mui/material' +import type { ResultType } from '../../domain/enums' + +type FilterSource = ResultType | 'ALL' +type SortBy = 'score' | 'rent' | 'area' + +interface Props { + filterSource: FilterSource + onFilterChange: (v: FilterSource) => void + sortBy: SortBy + onSortChange: (v: SortBy) => void +} + +const FILTER_OPTIONS: { value: FilterSource; label: string; color: string }[] = [ + { value: 'ALL', label: 'Alle', color: '#1e3a5f' }, + { value: 'VERIFIED_PORTFOLIO', label: 'Verified Portfolio', color: '#1e3a5f' }, + { value: 'EXTERNAL_MARKET', label: 'Marktinserate', color: '#d97706' }, + { value: 'FUTURE_AVAILABILITY', label: 'Zukunftssignale', color: '#7c3aed' }, +] + +const SORT_OPTIONS: { value: SortBy; label: string }[] = [ + { value: 'score', label: 'Relevanz' }, + { value: 'area', label: 'Fläche' }, + { value: 'rent', label: 'Mietpreis' }, +] + +export function ResultFilterBar({ filterSource, onFilterChange, sortBy, onSortChange }: Props) { + return ( + + + + {FILTER_OPTIONS.map(({ value, label, color }) => { + const active = filterSource === value + return ( + onFilterChange(value)} + sx={{ + bgcolor: active ? color : 'transparent', + color: active ? 'white' : 'text.secondary', + border: `1px solid ${active ? color : '#e2e8f0'}`, + fontWeight: active ? 600 : 400, + }} + /> + ) + })} + + + Sortierung: + {SORT_OPTIONS.map(({ value, label }) => ( + onSortChange(value)} + sx={{ + bgcolor: sortBy === value ? '#1e3a5f' : 'transparent', + color: sortBy === value ? 'white' : 'text.secondary', + border: `1px solid ${sortBy === value ? '#1e3a5f' : '#e2e8f0'}`, + }} + /> + ))} + + + + ) +} diff --git a/src/components/results/ResultTypeBadge.tsx b/src/components/results/ResultTypeBadge.tsx new file mode 100644 index 0000000..dd35a98 --- /dev/null +++ b/src/components/results/ResultTypeBadge.tsx @@ -0,0 +1,19 @@ +import type { ResultType } from '../../domain/enums' +import { Chip } from '@mui/material' + +const TYPE_META: Record = { + VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' }, + EXTERNAL_MARKET: { label: 'Marktinserat', color: '#d97706' }, + FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' }, +} + +export function ResultTypeBadge({ resultType }: { resultType: ResultType }) { + const meta = TYPE_META[resultType] ?? { label: resultType, color: '#64748b' } + return ( + + ) +} diff --git a/src/components/results/UnifiedResultCard.tsx b/src/components/results/UnifiedResultCard.tsx new file mode 100644 index 0000000..e8c2714 --- /dev/null +++ b/src/components/results/UnifiedResultCard.tsx @@ -0,0 +1,159 @@ +import { Alert, Box, Button, Card, Chip, Divider, Stack, Typography } from '@mui/material' +import { Banknote, Bookmark, Calendar, Columns2, MapPin, Maximize2 } from 'lucide-react' +import type { UnifiedMatchResult } from '../../domain/unifiedResult' +import type { Property } from '../../domain/property' +import type { FutureSignal } from '../../domain/futureSignal' +import { ResultConfidenceSummary } from './ResultConfidenceSummary' +import { ResultTypeBadge } from './ResultTypeBadge' + +function scoreColor(score: number): string { + if (score >= 78) return '#1a7a4a' + if (score >= 52) return '#d97706' + return '#c0392b' +} + +interface Props { + result: UnifiedMatchResult + isInCompare: boolean + onCompare: (propertyId: string) => void +} + +export function UnifiedResultCard({ result, isInCompare, onCompare }: Props) { + const { match, matchScore, resultType } = result + + const isFuture = resultType === 'FUTURE_AVAILABILITY' + const property: Property | undefined = !isFuture ? (result as { property: Property }).property : undefined + const signal: FutureSignal | undefined = isFuture ? (result as { signal: FutureSignal }).signal : undefined + + const compareId = property?.id ?? '' + + const title = property?.title ?? signal?.companyName ?? signal?.locationHint ?? '–' + const city = property?.location?.city ?? signal?.locationHint ?? '–' + const areaSqm = property?.areaSqm ?? signal?.areaSqmEstimate + const rent = property?.rentPricePerSqm + const available = property?.availabilityDate + const dqScore = property?.dataQuality?.score + + return ( + + {/* FUTURE_AVAILABILITY disclaimer — always shown, non-dismissable */} + {isFuture && ( + + {signal?.disclaimer ?? 'Probabilistisches Signal – kein bestätigtes Objekt'} + + )} + + {/* Header */} + + + + {title} + + + + {matchScore} + + /100 + + + + {/* Key facts */} + + + + {city} + + {areaSqm !== undefined && ( + + + {areaSqm} m² + + )} + {rent !== undefined && ( + + + CHF {rent}/m² + + )} + {available && ( + + + {available} + + )} + {isFuture && signal?.timeHorizonMonths && ( + + + + ~{signal.timeHorizonMonths} Monate · {Math.round(signal.probability * 100)}% Wahrscheinlichkeit + + + )} + + + + + {/* Positive factors */} + {match.positiveFactors.length > 0 && ( + + + Positive Faktoren + + + {match.positiveFactors.slice(0, 3).map((f, i) => ( + + ))} + + + )} + + {/* Tradeoffs */} + {(match.tradeoffs?.length ?? 0) > 0 && ( + + Abwägungen + {match.tradeoffs.slice(0, 2).map((t, i) => ( + + {t.criterion}: {t.concern} + + ))} + + )} + + {/* Confidence + data quality */} + + + + + {/* Actions */} + + + {!isFuture && ( + + )} + + + + ) +} diff --git a/src/components/results/UnifiedResultFeed.tsx b/src/components/results/UnifiedResultFeed.tsx new file mode 100644 index 0000000..ab8adbc --- /dev/null +++ b/src/components/results/UnifiedResultFeed.tsx @@ -0,0 +1,29 @@ +import type { UnifiedMatchResult } from '../../domain/unifiedResult' +import { UnifiedResultCard } from './UnifiedResultCard' + +interface Props { + results: UnifiedMatchResult[] + isInCompare: (id: string) => boolean + onCompare: (propertyId: string) => void +} + +export function UnifiedResultFeed({ results, isInCompare, onCompare }: Props) { + return ( + <> + {results.map(result => { + const compareId = + result.resultType !== 'FUTURE_AVAILABILITY' + ? (result as { property: { id: string } }).property.id + : '' + return ( + + ) + })} + + ) +} diff --git a/src/components/results/index.ts b/src/components/results/index.ts new file mode 100644 index 0000000..89456a5 --- /dev/null +++ b/src/components/results/index.ts @@ -0,0 +1,8 @@ +export { ResultTypeBadge } from './ResultTypeBadge' +export { ResultConfidenceSummary } from './ResultConfidenceSummary' +export { FeedEmptyState } from './FeedEmptyState' +export { FeedSkeleton } from './FeedSkeleton' +export { ResultFeedHeader } from './ResultFeedHeader' +export { ResultFilterBar } from './ResultFilterBar' +export { UnifiedResultCard } from './UnifiedResultCard' +export { UnifiedResultFeed } from './UnifiedResultFeed' diff --git a/src/pages/demand/Results.tsx b/src/pages/demand/Results.tsx index 76191a2..8259172 100644 --- a/src/pages/demand/Results.tsx +++ b/src/pages/demand/Results.tsx @@ -1,331 +1,72 @@ import { useState } from 'react' -import { - Box, - Button, - Card, - Chip, - Typography, - LinearProgress, - Stack, - Alert, - CircularProgress, - Divider, -} from '@mui/material' -import { - MapPin, - Maximize2, - Banknote, - Calendar, - Bookmark, - Columns2, -} from 'lucide-react' -import { useQuery } from '@tanstack/react-query' +import { Box, Button, Card, Stack, Typography } from '@mui/material' import { useNavigate } from 'react-router' -import { propertyService } from '../../services/propertyService' -import { matchService } from '../../services/matchService' -import { needService } from '../../services/needService' -import { ResultType, MatchStrength, RiskLevel } from '../../domain/enums' -import type { Property } from '../../domain/property' -import type { Match } from '../../domain/match' +import { useQuery } from '@tanstack/react-query' +import { useUnifiedResults } from '../../hooks/useUnifiedResults' import { useCompareStore } from '../../stores/compareStore' -import { EmptyState } from '../../components/ui' +import { needService } from '../../services/needService' +import { + FeedEmptyState, + FeedSkeleton, + ResultFeedHeader, + ResultFilterBar, + UnifiedResultFeed, +} from '../../components/results' +import type { ResultType } from '../../domain/enums' +import type { UnifiedMatchResult } from '../../domain/unifiedResult' type FilterSource = ResultType | 'ALL' type SortBy = 'score' | 'rent' | 'area' -function getResultTypeLabel(type: ResultType): string { - switch (type) { - case ResultType.VERIFIED_PORTFOLIO: return 'Verified Portfolio' - case ResultType.EXTERNAL_MARKET: return 'Marktinserat' - case ResultType.FUTURE_AVAILABILITY: return 'Zukunftssignal' - } -} - -function getResultTypeColor(type: ResultType): string { - switch (type) { - case ResultType.VERIFIED_PORTFOLIO: return '#1e3a5f' - case ResultType.EXTERNAL_MARKET: return '#d97706' - case ResultType.FUTURE_AVAILABILITY: return '#7c3aed' - } -} - -function getMatchStrengthColor(strength: MatchStrength): string { - switch (strength) { - case MatchStrength.STRONG: return '#1a7a4a' - case MatchStrength.MODERATE: return '#d97706' - case MatchStrength.WEAK: return '#c0392b' - } -} - -function getRiskChipColor(risk: RiskLevel): 'success' | 'warning' | 'error' { - if (risk === RiskLevel.LOW) return 'success' - if (risk === RiskLevel.MEDIUM) return 'warning' - return 'error' -} - -function getRiskLabel(risk: RiskLevel): string { - switch (risk) { - case RiskLevel.LOW: return 'Niedriges Risiko' - case RiskLevel.MEDIUM: return 'Mittleres Risiko' - case RiskLevel.HIGH: return 'Hohes Risiko' - case RiskLevel.CRITICAL: return 'Kritisches Risiko' - } -} - -interface ResultCardProps { - property: Property - match: Match - onCompare: (id: string) => void - isInCompare: boolean -} - -function ResultCard({ property, match, onCompare, isInCompare }: ResultCardProps) { - const scoreColor = getMatchStrengthColor(match.matchStrength) - - return ( - - {/* Future signal warning */} - {property.resultType === ResultType.FUTURE_AVAILABILITY && ( - - Probabilistisches Signal – kein bestätigtes Objekt - - )} - - {/* Header row */} - - - - - {property.title} - - - - - {match.matchScore} - - /100 - - - - {/* Property details row */} - - - - - {property.location.city} - {property.location.district ? `, ${property.location.district}` : ''} - - - - - - {property.areaSqm} m² - - - - - - CHF {property.rentPricePerSqm}/m² - - - - - - {property.availabilityDate} - - - - - {/* Match factors section */} - - - {match.positiveFactors.length > 0 && ( - - - Positive Faktoren - - - {match.positiveFactors.slice(0, 3).map((f, i) => ( - - ))} - - - )} - - {match.tradeoffs.length > 0 && ( - - Abwägungen - {match.tradeoffs.slice(0, 2).map((t, i) => ( - - {t.criterion}: {t.concern} - - ))} - - )} - - {/* Confidence + Quality row */} - - - Konfidenz - = 0.8 ? '#1a7a4a' : match.confidenceLevel >= 0.6 ? '#d97706' : '#c0392b' }}> - {Math.round(match.confidenceLevel * 100)}% - - - - Datenqualität - - = 0.8 ? 'success' : - property.dataQuality.score >= 0.6 ? 'warning' : 'error' }} - /> - - - {Math.round(property.dataQuality.score * 100)}% - - - {property.riskLevel && ( - - )} - - - {/* Actions row */} - - - - - - - ) +function sortResults(results: UnifiedMatchResult[], sortBy: SortBy): UnifiedMatchResult[] { + return [...results].sort((a, b) => { + if (sortBy === 'score') return b.matchScore - a.matchScore + const propA = a.resultType !== 'FUTURE_AVAILABILITY' ? (a as { property: { rentPricePerSqm: number; areaSqm: number } }).property : null + const propB = b.resultType !== 'FUTURE_AVAILABILITY' ? (b as { property: { rentPricePerSqm: number; areaSqm: number } }).property : null + if (sortBy === 'rent') return (propA?.rentPricePerSqm ?? 0) - (propB?.rentPricePerSqm ?? 0) + if (sortBy === 'area') return (propB?.areaSqm ?? 0) - (propA?.areaSqm ?? 0) + return 0 + }) } export default function Results() { const navigate = useNavigate() - const [filterSource, setFilterSource] = useState('ALL') const [sortBy, setSortBy] = useState('score') - const { data: propResp, isLoading: propLoading } = useQuery({ - queryKey: ['properties'], - queryFn: () => propertyService.getAll(), - }) - const { data: matchResp, isLoading: matchLoading } = useQuery({ - queryKey: ['matches'], - queryFn: () => matchService.getAll(), - }) - const { data: needResp, isLoading: needLoading } = useQuery({ + const { data: results = [], isLoading } = useUnifiedResults() + const { data: needResp } = useQuery({ queryKey: ['needs'], queryFn: () => needService.getAll(), }) - const { addToCompare, removeFromCompare, clearCompare, isInCompare, compareTray } = useCompareStore() - const properties = propResp?.data ?? [] - const matches = matchResp?.data ?? [] - const needs = needResp?.data ?? [] - const activeNeed = needs[0] + const activeNeed = needResp?.data?.[0] - const isLoading = propLoading || matchLoading || needLoading + const filtered = + filterSource === 'ALL' ? results : results.filter(r => r.resultType === filterSource) - // Join matches with properties - const resultItems = matches - .map(match => { - const property = properties.find(p => p.id === match.propertyId) - return property ? { match, property } : null - }) - .filter((item): item is { match: Match; property: Property } => item !== null) + const sorted = sortResults(filtered, sortBy) - // Filter by source - const filtered = filterSource === 'ALL' - ? resultItems - : resultItems.filter(item => item.property.resultType === filterSource) + const verifiedCount = results.filter(r => r.resultType === 'VERIFIED_PORTFOLIO').length + const externalCount = results.filter(r => r.resultType === 'EXTERNAL_MARKET').length + const futureCount = results.filter(r => r.resultType === 'FUTURE_AVAILABILITY').length - // Sort - const sorted = [...filtered].sort((a, b) => { - if (sortBy === 'score') return b.match.matchScore - a.match.matchScore - if (sortBy === 'rent') return a.property.rentPricePerSqm - b.property.rentPricePerSqm - if (sortBy === 'area') return b.property.areaSqm - a.property.areaSqm - return 0 - }) - - const verifiedCount = resultItems.filter(i => i.property.resultType === ResultType.VERIFIED_PORTFOLIO).length - const externalCount = resultItems.filter(i => i.property.resultType === ResultType.EXTERNAL_MARKET).length - const futureCount = resultItems.filter(i => i.property.resultType === ResultType.FUTURE_AVAILABILITY).length - - const handleToggleCompare = (id: string) => { - if (isInCompare(id)) { - removeFromCompare(id) - } else { - addToCompare(id) - } - } - - if (isLoading) { - return ( - - - - ) + const handleCompare = (propertyId: string) => { + if (isInCompare(propertyId)) removeFromCompare(propertyId) + else addToCompare(propertyId) } return ( - {/* Page Header */} - - - - {sorted.length} Treffer gefunden - - - {verifiedCount} Verified · {externalCount} Extern · {futureCount} Signale - - - + - {/* Active Need Banner */} {activeNeed && ( @@ -350,81 +91,22 @@ export default function Results() { )} - {/* Filter/Sort bar */} - - - - {(['ALL', ResultType.VERIFIED_PORTFOLIO, ResultType.EXTERNAL_MARKET, ResultType.FUTURE_AVAILABILITY] as FilterSource[]).map(source => { - const labels: Record = { - ALL: 'Alle', - [ResultType.VERIFIED_PORTFOLIO]: 'Verified Portfolio', - [ResultType.EXTERNAL_MARKET]: 'Marktinserate', - [ResultType.FUTURE_AVAILABILITY]: 'Zukunftssignale', - } - const colors: Partial> = { - [ResultType.VERIFIED_PORTFOLIO]: '#1e3a5f', - [ResultType.EXTERNAL_MARKET]: '#d97706', - [ResultType.FUTURE_AVAILABILITY]: '#7c3aed', - } - const isActive = filterSource === source - return ( - setFilterSource(source)} - sx={{ - bgcolor: isActive ? (colors[source] ?? '#1e3a5f') : 'transparent', - color: isActive ? 'white' : 'text.secondary', - border: `1px solid ${isActive ? (colors[source] ?? '#1e3a5f') : '#e2e8f0'}`, - fontWeight: isActive ? 600 : 400, - }} - /> - ) - })} - + - - Sortierung: - {([['score', 'Relevanz'], ['area', 'Fläche'], ['rent', 'Mietpreis']] as [SortBy, string][]).map(([val, label]) => ( - setSortBy(val)} - sx={{ - bgcolor: sortBy === val ? '#1e3a5f' : 'transparent', - color: sortBy === val ? 'white' : 'text.secondary', - border: `1px solid ${sortBy === val ? '#1e3a5f' : '#e2e8f0'}`, - }} - /> - ))} - - - - - {/* Results */} - {sorted.length === 0 ? ( - + {isLoading ? ( + + ) : sorted.length === 0 ? ( + ) : ( - sorted.map(({ match, property }) => ( - - )) + )} - {/* Compare Tray */} {compareTray.length > 0 && (