Files
property-match/src/pages/demand/Results.tsx
T
Benjamin Sutter 307b450807 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 <noreply@anthropic.com>
2026-05-16 13:18:58 +02:00

154 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
import { Box, Button, Card, Stack, Typography } from '@mui/material'
import { useNavigate } from 'react-router'
import { useQuery } from '@tanstack/react-query'
import { useUnifiedResults } from '../../hooks/useUnifiedResults'
import { useCompareStore } from '../../stores/compareStore'
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 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<FilterSource>('ALL')
const [sortBy, setSortBy] = useState<SortBy>('score')
const { data: results = [], isLoading } = useUnifiedResults()
const { data: needResp } = useQuery({
queryKey: ['needs'],
queryFn: () => needService.getAll(),
})
const { addToCompare, removeFromCompare, clearCompare, isInCompare, compareTray } = useCompareStore()
const activeNeed = needResp?.data?.[0]
const filtered =
filterSource === 'ALL' ? results : results.filter(r => r.resultType === filterSource)
const sorted = sortResults(filtered, sortBy)
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
const handleCompare = (propertyId: string) => {
if (isInCompare(propertyId)) removeFromCompare(propertyId)
else addToCompare(propertyId)
}
return (
<Box>
<ResultFeedHeader
total={sorted.length}
verifiedCount={verifiedCount}
externalCount={externalCount}
futureCount={futureCount}
/>
<Box sx={{ px: 3, py: 3 }}>
{activeNeed && (
<Card sx={{ bgcolor: '#eff6ff', p: 2, mb: 2, border: '1px solid #bfdbfe' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }} color="#1e3a5f">
Aktive Suche: {activeNeed.companyName}
</Typography>
<Typography variant="caption" color="text.secondary">
{activeNeed.assetType} · {activeNeed.requiredArea.min}{activeNeed.requiredArea.max} m² ·{' '}
{activeNeed.preferredLocations.join(', ')}
</Typography>
</Box>
<Button
size="small"
variant="text"
onClick={() => navigate('/demand/ai-search')}
sx={{ color: '#1e3a5f' }}
>
Suche ändern
</Button>
</Box>
</Card>
)}
<ResultFilterBar
filterSource={filterSource}
onFilterChange={setFilterSource}
sortBy={sortBy}
onSortChange={setSortBy}
/>
{isLoading ? (
<FeedSkeleton />
) : sorted.length === 0 ? (
<FeedEmptyState filtered={filterSource !== 'ALL'} />
) : (
<UnifiedResultFeed results={sorted} isInCompare={isInCompare} onCompare={handleCompare} />
)}
</Box>
{compareTray.length > 0 && (
<Box
sx={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
bgcolor: '#1e3a5f',
color: 'white',
py: 1.5,
px: 3,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
zIndex: 1200,
boxShadow: '0 -4px 12px rgba(0,0,0,0.15)',
}}
>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{compareTray.length} Objekte zum Vergleich ausgewählt
</Typography>
<Stack direction="row" spacing={1}>
<Button
size="small"
variant="outlined"
sx={{ color: 'white', borderColor: 'rgba(255,255,255,0.5)' }}
onClick={clearCompare}
>
Leeren
</Button>
<Button
size="small"
variant="contained"
sx={{ bgcolor: 'white', color: '#1e3a5f', '&:hover': { bgcolor: '#f1f5f9' } }}
onClick={() => navigate('/demand/compare')}
>
Vergleich starten
</Button>
</Stack>
</Box>
)}
</Box>
)
}