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 */}
+
+ } disabled>
+ Shortlist
+
+ {!isFuture && (
+ }
+ onClick={() => onCompare(compareId)}
+ sx={isInCompare ? { bgcolor: '#1e3a5f' } : {}}
+ >
+ Vergleichen
+
+ )}
+
+
+
+ )
+}
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 */}
-
- }
- disabled
- >
- Shortlist
-
- }
- onClick={() => onCompare(property.id)}
- sx={isInCompare ? { bgcolor: '#1e3a5f' } : {}}
- >
- Vergleichen
-
-
-
-
- )
+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 && (