feat: mix Schattenmarkt into result tiers, add filter toggle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-19 22:36:27 +02:00
parent b6f65f4763
commit a521155bb2
3 changed files with 72 additions and 81 deletions
+60 -27
View File
@@ -1,7 +1,8 @@
import { Box, Card, Chip, Stack, Typography } from '@mui/material' import { Box, Card, Chip, Divider, Stack, Typography } from '@mui/material'
import { Zap } from 'lucide-react'
import type { ResultType } from '../../domain/enums' import type { ResultType } from '../../domain/enums'
type FilterSource = ResultType | 'ALL' type FilterSource = Exclude<ResultType, 'FUTURE_AVAILABILITY'> | 'ALL'
type SortBy = 'score' | 'rent' | 'area' type SortBy = 'score' | 'rent' | 'area'
interface Props { interface Props {
@@ -9,13 +10,14 @@ interface Props {
onFilterChange: (v: FilterSource) => void onFilterChange: (v: FilterSource) => void
sortBy: SortBy sortBy: SortBy
onSortChange: (v: SortBy) => void onSortChange: (v: SortBy) => void
showSchattenmarkt: boolean
onShowSchattenmarktChange: (v: boolean) => void
} }
const FILTER_OPTIONS: { value: FilterSource; label: string; color: string }[] = [ const FILTER_OPTIONS: { value: FilterSource; label: string; color: string }[] = [
{ value: 'ALL', label: 'Alle', color: '#1e3a5f' }, { value: 'ALL', label: 'Alle', color: '#1e3a5f' },
{ value: 'VERIFIED_PORTFOLIO', label: 'Verified Portfolio', color: '#1e3a5f' }, { value: 'VERIFIED_PORTFOLIO', label: 'Portfolio', color: '#1e3a5f' },
{ value: 'EXTERNAL_MARKET', label: 'Marktinserate', color: '#d97706' }, { value: 'EXTERNAL_MARKET', label: 'Marktinserate', color: '#d97706' },
{ value: 'FUTURE_AVAILABILITY', label: 'Zukunftssignale', color: '#7c3aed' },
] ]
const SORT_OPTIONS: { value: SortBy; label: string }[] = [ const SORT_OPTIONS: { value: SortBy; label: string }[] = [
@@ -24,30 +26,61 @@ const SORT_OPTIONS: { value: SortBy; label: string }[] = [
{ value: 'rent', label: 'Mietpreis' }, { value: 'rent', label: 'Mietpreis' },
] ]
export function ResultFilterBar({ filterSource, onFilterChange, sortBy, onSortChange }: Props) { export function ResultFilterBar({
filterSource,
onFilterChange,
sortBy,
onSortChange,
showSchattenmarkt,
onShowSchattenmarktChange,
}: Props) {
return ( return (
<Card sx={{ p: 1.5, mb: 2 }}> <Card sx={{ p: 1.5, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 1 }}>
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5 }}> {/* Source filter + Schattenmarkt toggle */}
{FILTER_OPTIONS.map(({ value, label, color }) => { <Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 0.5 }}>
const active = filterSource === value <Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5 }}>
return ( {FILTER_OPTIONS.map(({ value, label, color }) => {
<Chip const active = filterSource === value
key={value} return (
label={label} <Chip
size="small" key={value}
clickable label={label}
onClick={() => onFilterChange(value)} size="small"
sx={{ clickable
bgcolor: active ? color : 'transparent', onClick={() => onFilterChange(value)}
color: active ? 'white' : 'text.secondary', sx={{
border: `1px solid ${active ? color : '#e2e8f0'}`, bgcolor: active ? color : 'transparent',
fontWeight: active ? 600 : 400, color: active ? 'white' : 'text.secondary',
}} border: `1px solid ${active ? color : '#e2e8f0'}`,
/> fontWeight: active ? 600 : 400,
) }}
})} />
</Stack> )
})}
</Stack>
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
{/* Schattenmarkt toggle */}
<Chip
size="small"
clickable
icon={<Zap size={11} color={showSchattenmarkt ? '#7c3aed' : '#94a3b8'} />}
label="Schattenmarkt"
onClick={() => onShowSchattenmarktChange(!showSchattenmarkt)}
sx={{
bgcolor: showSchattenmarkt ? '#f3e8ff' : 'transparent',
color: showSchattenmarkt ? '#6d28d9' : 'text.disabled',
border: `1px solid ${showSchattenmarkt ? '#c4b5fd' : '#e2e8f0'}`,
fontWeight: showSchattenmarkt ? 600 : 400,
textDecoration: showSchattenmarkt ? 'none' : 'line-through',
'& .MuiChip-icon': { ml: 0.75 },
}}
/>
</Box>
{/* Sort */}
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'center' }}> <Stack direction="row" spacing={0.5} sx={{ alignItems: 'center' }}>
<Typography variant="caption" color="text.secondary">Sortierung:</Typography> <Typography variant="caption" color="text.secondary">Sortierung:</Typography>
{SORT_OPTIONS.map(({ value, label }) => ( {SORT_OPTIONS.map(({ value, label }) => (
+4 -50
View File
@@ -1,5 +1,4 @@
import { Alert, Box, Chip, Divider, Typography } from '@mui/material' import { Box, Chip, Divider, Typography } from '@mui/material'
import { Zap } from 'lucide-react'
import type { UnifiedMatchResult } from '../../domain/unifiedResult' import type { UnifiedMatchResult } from '../../domain/unifiedResult'
import { UnifiedResultCard } from './UnifiedResultCard' import { UnifiedResultCard } from './UnifiedResultCard'
import { SCORE_THEME } from '../shared/scoreTheme' import { SCORE_THEME } from '../shared/scoreTheme'
@@ -73,46 +72,10 @@ function TierHeader({ label, sublabel, tierKey, count, isFirst }: TierHeaderProp
) )
} }
function SchattenmarktSectionHeader({ count }: { count: number }) {
return (
<Box sx={{ mt: 4, mb: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1.5 }}>
<Box sx={{ width: 4, height: 28, borderRadius: 2, background: 'linear-gradient(180deg, #7c3aed, #4c1d95)', flexShrink: 0 }} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Zap size={15} color="#7c3aed" />
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.2, color: '#4c1d95' }}>
Schattenmarkt
</Typography>
<Chip
label={count}
size="small"
sx={{ fontWeight: 700, fontSize: '0.75rem', bgcolor: '#ede9fe', color: '#6d28d9', border: '1px solid #c4b5fd' }}
/>
</Box>
<Divider sx={{ flex: 1 }} />
</Box>
<Alert
severity="info"
icon={<Zap size={16} />}
sx={{
mb: 2,
bgcolor: '#faf5ff',
border: '1px solid #e9d5ff',
color: '#4c1d95',
'& .MuiAlert-icon': { color: '#7c3aed' },
}}
>
<strong>Was sind Schattenmarkt-Signale?</strong> Die KI hat aus öffentlichen Quellen (Stelleninseraten, Pressemeldungen, Baubewilligungen etc.) Hinweise gefunden, dass in Ihrer Zielregion in absehbarer Zeit Flächen frei werden könnten. Dies sind <strong>keine bestätigten Angebote</strong> aber frühzeitige Hinweise, die Sie im Auge behalten sollten. Klicken Sie auf Details für Quellen und die vollständige KI-Analyse.
</Alert>
</Box>
)
}
export function UnifiedResultFeed({ results, view = 'list' }: Props) { export function UnifiedResultFeed({ results, view = 'list' }: Props) {
const realResults = results.filter(r => r.resultType !== 'FUTURE_AVAILABILITY') // All results — real properties and Schattenmarkt signals — are sorted into
const signalResults = results.filter(r => r.resultType === 'FUTURE_AVAILABILITY') // the same score tiers so they appear in a single unified ranked list.
const tiers = TIER_CONFIG.map(t => ({ ...t, items: results.filter(t.filter) }))
const tiers = TIER_CONFIG.map(t => ({ ...t, items: realResults.filter(t.filter) }))
.filter(t => t.items.length > 0) .filter(t => t.items.length > 0)
function renderCards(items: UnifiedMatchResult[]) { function renderCards(items: UnifiedMatchResult[]) {
@@ -133,7 +96,6 @@ export function UnifiedResultFeed({ results, view = 'list' }: Props) {
return ( return (
<Box> <Box>
{/* Real properties — scored tiers */}
{tiers.map((tier, idx) => ( {tiers.map((tier, idx) => (
<Box key={tier.key}> <Box key={tier.key}>
<TierHeader <TierHeader
@@ -146,14 +108,6 @@ export function UnifiedResultFeed({ results, view = 'list' }: Props) {
{renderCards(tier.items)} {renderCards(tier.items)}
</Box> </Box>
))} ))}
{/* Schattenmarkt signals — dedicated section */}
{signalResults.length > 0 && (
<Box>
<SchattenmarktSectionHeader count={signalResults.length} />
{renderCards(signalResults)}
</Box>
)}
</Box> </Box>
) )
} }
+8 -4
View File
@@ -16,7 +16,7 @@ import { AddToShortlistDialog } from '../../components/shortlist'
import type { ResultType } from '../../domain/enums' import type { ResultType } from '../../domain/enums'
import type { UnifiedMatchResult } from '../../domain/unifiedResult' import type { UnifiedMatchResult } from '../../domain/unifiedResult'
type FilterSource = ResultType | 'ALL' type FilterSource = Exclude<ResultType, 'FUTURE_AVAILABILITY'> | 'ALL'
type SortBy = 'score' | 'rent' | 'area' type SortBy = 'score' | 'rent' | 'area'
function sortResults(results: UnifiedMatchResult[], sortBy: SortBy): UnifiedMatchResult[] { function sortResults(results: UnifiedMatchResult[], sortBy: SortBy): UnifiedMatchResult[] {
@@ -36,6 +36,7 @@ export default function Results() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [filterSource, setFilterSource] = useState<FilterSource>('ALL') const [filterSource, setFilterSource] = useState<FilterSource>('ALL')
const [sortBy, setSortBy] = useState<SortBy>('score') const [sortBy, setSortBy] = useState<SortBy>('score')
const [showSchattenmarkt, setShowSchattenmarkt] = useState(true)
const [view, setView] = useState<'list' | 'grid'>(() => const [view, setView] = useState<'list' | 'grid'>(() =>
(localStorage.getItem('view-results') as 'list' | 'grid') ?? 'list' (localStorage.getItem('view-results') as 'list' | 'grid') ?? 'list'
) )
@@ -67,8 +68,9 @@ export default function Results() {
const { data: results = [], isLoading } = useUnifiedResults(activeNeed?.id) const { data: results = [], isLoading } = useUnifiedResults(activeNeed?.id)
const filtered = const filtered = results
filterSource === 'ALL' ? results : results.filter(r => r.resultType === filterSource) .filter(r => filterSource === 'ALL' || r.resultType === filterSource)
.filter(r => showSchattenmarkt || r.resultType !== 'FUTURE_AVAILABILITY')
const sorted = sortResults(filtered, sortBy) const sorted = sortResults(filtered, sortBy)
@@ -167,12 +169,14 @@ export default function Results() {
onFilterChange={setFilterSource} onFilterChange={setFilterSource}
sortBy={sortBy} sortBy={sortBy}
onSortChange={setSortBy} onSortChange={setSortBy}
showSchattenmarkt={showSchattenmarkt}
onShowSchattenmarktChange={setShowSchattenmarkt}
/> />
{isLoading ? ( {isLoading ? (
<FeedSkeleton /> <FeedSkeleton />
) : sorted.length === 0 ? ( ) : sorted.length === 0 ? (
<FeedEmptyState filtered={filterSource !== 'ALL'} /> <FeedEmptyState filtered={filterSource !== 'ALL' || !showSchattenmarkt} />
) : ( ) : (
<UnifiedResultFeed results={sorted} view={view} /> <UnifiedResultFeed results={sorted} view={view} />
)} )}