Files
property-match/src/pages/demand/Results.tsx
T
Benjamin Sutter 65130efca7 feat: F015 shortlist management
3-panel Shortlists page with left list, center detail, right decision brief panel. AddToShortlistDialog wired into result feed cards and match detail. Full DRAFT→REVIEW_READY→FINALIZED workflow, AI-generated decision brief draft, duplicate prevention, and compare-view integration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:10:06 +02:00

150 lines
6.1 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, Typography } from '@mui/material'
import { useNavigate, useLocation } from 'react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useUnifiedResults } from '../../hooks/useUnifiedResults'
import { needService } from '../../services/needService'
import {
FeedEmptyState,
FeedSkeleton,
ResultFeedHeader,
ResultFilterBar,
UnifiedResultFeed,
} from '../../components/results'
import { AddToShortlistDialog } from '../../components/shortlist'
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 location = useLocation()
const queryClient = useQueryClient()
const [filterSource, setFilterSource] = useState<FilterSource>('ALL')
const [sortBy, setSortBy] = useState<SortBy>('score')
// When coming from NeedBuilder, invalidate so the freshly created need is included
const activeNeedIdFromNav = (location.state as { activeNeedId?: string } | null)?.activeNeedId
const { data: needResp } = useQuery({
queryKey: ['needs'],
queryFn: () => needService.getAll(),
// Refetch on mount when navigating from NeedBuilder to pick up the new need
refetchOnMount: activeNeedIdFromNav ? 'always' : true,
gcTime: 0,
})
// Prefer the ID passed from NeedBuilder; fall back to most-recently-created
const activeNeed = needResp?.data
? activeNeedIdFromNav
? (needResp.data.find(n => n.id === activeNeedIdFromNav) ?? needResp.data[0])
: [...needResp.data].sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)[0]
: undefined
// Ensure query cache is invalidated when a new need was just created
if (activeNeedIdFromNav) {
queryClient.invalidateQueries({ queryKey: ['needs'] })
}
const { data: results = [], isLoading } = useUnifiedResults(activeNeed?.id)
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
return (
<Box>
<AddToShortlistDialog />
<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: 'flex-start', justifyContent: 'space-between', gap: 2 }}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }} color="#1e3a5f">
Aktive Suche: {activeNeed.companyName}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: '4px 16px' }}>
<Typography variant="caption" color="text.secondary">
<strong>Typ:</strong> {activeNeed.assetType}
</Typography>
<Typography variant="caption" color="text.secondary">
<strong>Fläche:</strong> {activeNeed.requiredArea.min}{activeNeed.requiredArea.max} m²
</Typography>
<Typography variant="caption" color="text.secondary">
<strong>Standort:</strong> {activeNeed.preferredLocations.join(', ')}
</Typography>
{activeNeed.budgetRange && (
<Typography variant="caption" color="text.secondary">
<strong>Budget:</strong> max. CHF {activeNeed.budgetRange.maxPerSqm}/m²
</Typography>
)}
{activeNeed.timing && (
<Typography variant="caption" color="text.secondary">
<strong>Bezug ab:</strong> {new Date(activeNeed.timing.earliestMoveIn).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' })}
</Typography>
)}
{activeNeed.mustCriteriaText && activeNeed.mustCriteriaText.length > 0 && (
<Typography variant="caption" color="text.secondary">
<strong>Must-haves:</strong> {activeNeed.mustCriteriaText.slice(0, 3).join(' · ')}
</Typography>
)}
</Box>
</Box>
<Button
size="small"
variant="text"
onClick={() => navigate('/demand/ai-search')}
sx={{ color: '#1e3a5f', flexShrink: 0 }}
>
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} />
)}
</Box>
</Box>
)
}