feat: mix Schattenmarkt into result tiers, add filter toggle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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'
|
||||
|
||||
type FilterSource = ResultType | 'ALL'
|
||||
type FilterSource = Exclude<ResultType, 'FUTURE_AVAILABILITY'> | 'ALL'
|
||||
type SortBy = 'score' | 'rent' | 'area'
|
||||
|
||||
interface Props {
|
||||
@@ -9,13 +10,14 @@ interface Props {
|
||||
onFilterChange: (v: FilterSource) => void
|
||||
sortBy: SortBy
|
||||
onSortChange: (v: SortBy) => void
|
||||
showSchattenmarkt: boolean
|
||||
onShowSchattenmarktChange: (v: boolean) => 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' },
|
||||
{ value: 'ALL', label: 'Alle', color: '#1e3a5f' },
|
||||
{ value: 'VERIFIED_PORTFOLIO', label: 'Portfolio', color: '#1e3a5f' },
|
||||
{ value: 'EXTERNAL_MARKET', label: 'Marktinserate', color: '#d97706' },
|
||||
]
|
||||
|
||||
const SORT_OPTIONS: { value: SortBy; label: string }[] = [
|
||||
@@ -24,30 +26,61 @@ const SORT_OPTIONS: { value: SortBy; label: string }[] = [
|
||||
{ value: 'rent', label: 'Mietpreis' },
|
||||
]
|
||||
|
||||
export function ResultFilterBar({ filterSource, onFilterChange, sortBy, onSortChange }: Props) {
|
||||
export function ResultFilterBar({
|
||||
filterSource,
|
||||
onFilterChange,
|
||||
sortBy,
|
||||
onSortChange,
|
||||
showSchattenmarkt,
|
||||
onShowSchattenmarktChange,
|
||||
}: Props) {
|
||||
return (
|
||||
<Card sx={{ p: 1.5, mb: 2 }}>
|
||||
<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 }}>
|
||||
{FILTER_OPTIONS.map(({ value, label, color }) => {
|
||||
const active = filterSource === value
|
||||
return (
|
||||
<Chip
|
||||
key={value}
|
||||
label={label}
|
||||
size="small"
|
||||
clickable
|
||||
onClick={() => onFilterChange(value)}
|
||||
sx={{
|
||||
bgcolor: active ? color : 'transparent',
|
||||
color: active ? 'white' : 'text.secondary',
|
||||
border: `1px solid ${active ? color : '#e2e8f0'}`,
|
||||
fontWeight: active ? 600 : 400,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Stack>
|
||||
{/* Source filter + Schattenmarkt toggle */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{FILTER_OPTIONS.map(({ value, label, color }) => {
|
||||
const active = filterSource === value
|
||||
return (
|
||||
<Chip
|
||||
key={value}
|
||||
label={label}
|
||||
size="small"
|
||||
clickable
|
||||
onClick={() => onFilterChange(value)}
|
||||
sx={{
|
||||
bgcolor: active ? color : 'transparent',
|
||||
color: active ? 'white' : 'text.secondary',
|
||||
border: `1px solid ${active ? color : '#e2e8f0'}`,
|
||||
fontWeight: active ? 600 : 400,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</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' }}>
|
||||
<Typography variant="caption" color="text.secondary">Sortierung:</Typography>
|
||||
{SORT_OPTIONS.map(({ value, label }) => (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Alert, Box, Chip, Divider, Typography } from '@mui/material'
|
||||
import { Zap } from 'lucide-react'
|
||||
import { Box, Chip, Divider, Typography } from '@mui/material'
|
||||
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||
import { UnifiedResultCard } from './UnifiedResultCard'
|
||||
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) {
|
||||
const realResults = results.filter(r => r.resultType !== 'FUTURE_AVAILABILITY')
|
||||
const signalResults = results.filter(r => r.resultType === 'FUTURE_AVAILABILITY')
|
||||
|
||||
const tiers = TIER_CONFIG.map(t => ({ ...t, items: realResults.filter(t.filter) }))
|
||||
// All results — real properties and Schattenmarkt signals — are sorted into
|
||||
// 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) }))
|
||||
.filter(t => t.items.length > 0)
|
||||
|
||||
function renderCards(items: UnifiedMatchResult[]) {
|
||||
@@ -133,7 +96,6 @@ export function UnifiedResultFeed({ results, view = 'list' }: Props) {
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Real properties — scored tiers */}
|
||||
{tiers.map((tier, idx) => (
|
||||
<Box key={tier.key}>
|
||||
<TierHeader
|
||||
@@ -146,14 +108,6 @@ export function UnifiedResultFeed({ results, view = 'list' }: Props) {
|
||||
{renderCards(tier.items)}
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{/* Schattenmarkt signals — dedicated section */}
|
||||
{signalResults.length > 0 && (
|
||||
<Box>
|
||||
<SchattenmarktSectionHeader count={signalResults.length} />
|
||||
{renderCards(signalResults)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { AddToShortlistDialog } from '../../components/shortlist'
|
||||
import type { ResultType } from '../../domain/enums'
|
||||
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||
|
||||
type FilterSource = ResultType | 'ALL'
|
||||
type FilterSource = Exclude<ResultType, 'FUTURE_AVAILABILITY'> | 'ALL'
|
||||
type SortBy = 'score' | 'rent' | 'area'
|
||||
|
||||
function sortResults(results: UnifiedMatchResult[], sortBy: SortBy): UnifiedMatchResult[] {
|
||||
@@ -36,6 +36,7 @@ export default function Results() {
|
||||
const queryClient = useQueryClient()
|
||||
const [filterSource, setFilterSource] = useState<FilterSource>('ALL')
|
||||
const [sortBy, setSortBy] = useState<SortBy>('score')
|
||||
const [showSchattenmarkt, setShowSchattenmarkt] = useState(true)
|
||||
const [view, setView] = useState<'list' | 'grid'>(() =>
|
||||
(localStorage.getItem('view-results') as 'list' | 'grid') ?? 'list'
|
||||
)
|
||||
@@ -67,8 +68,9 @@ export default function Results() {
|
||||
|
||||
const { data: results = [], isLoading } = useUnifiedResults(activeNeed?.id)
|
||||
|
||||
const filtered =
|
||||
filterSource === 'ALL' ? results : results.filter(r => r.resultType === filterSource)
|
||||
const filtered = results
|
||||
.filter(r => filterSource === 'ALL' || r.resultType === filterSource)
|
||||
.filter(r => showSchattenmarkt || r.resultType !== 'FUTURE_AVAILABILITY')
|
||||
|
||||
const sorted = sortResults(filtered, sortBy)
|
||||
|
||||
@@ -167,12 +169,14 @@ export default function Results() {
|
||||
onFilterChange={setFilterSource}
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
showSchattenmarkt={showSchattenmarkt}
|
||||
onShowSchattenmarktChange={setShowSchattenmarkt}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<FeedSkeleton />
|
||||
) : sorted.length === 0 ? (
|
||||
<FeedEmptyState filtered={filterSource !== 'ALL'} />
|
||||
<FeedEmptyState filtered={filterSource !== 'ALL' || !showSchattenmarkt} />
|
||||
) : (
|
||||
<UnifiedResultFeed results={sorted} view={view} />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user