feat: Steuerlast-Kriterium, ImmoScout-Layout, Gold/Silver/Bronze-Trennung
- Flächensuche: Flexibilität durch Steuerlast (taxEnvironment) ersetzt - MatchDetail: Bild als Hero-Banner oben, Karte eingebettet im Inhalt darunter - Ergebnisliste: Gold/Silber/Bronze-Abschnitte mit farbigen Trennlinien - ScoreBreakdown: KI-Steuerlast-Link zur kantonalen Steuerrechner-Seite - Beispieldaten: alle Objekte mit passenden Bildern versehen - locationIntelligence: taxCalculatorUrl pro Kanton ergänzt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -64,11 +64,13 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontWeight: 900, fontSize: '1.5rem', color: theme.text, lineHeight: 1 }}>
|
||||
{vm.matchScore}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.575rem', color: theme.text, opacity: 0.8, textTransform: 'uppercase', letterSpacing: 0.8, lineHeight: 1.2 }}>
|
||||
{theme.label}
|
||||
{vm.matchScore}%
|
||||
</Typography>
|
||||
{tier !== 'bronze' && (
|
||||
<Typography sx={{ fontSize: '0.575rem', color: theme.text, opacity: 0.8, textTransform: 'uppercase', letterSpacing: 0.8, lineHeight: 1.2 }}>
|
||||
{theme.label}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Result type chip — overlaid top-right */}
|
||||
@@ -123,6 +125,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
{/* Actions */}
|
||||
<Box sx={{ display: 'flex', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
|
||||
{vm.actions.map(a => (
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface MatchCardViewModel {
|
||||
actions: MatchCardAction[]
|
||||
disclaimer?: string // required for FUTURE_AVAILABILITY
|
||||
explainabilitySummary?: string
|
||||
taxCalculatorUrl?: string // deeplink to cantonal tax calculator
|
||||
|
||||
// States
|
||||
isSelected?: boolean
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Box, Divider, LinearProgress, Paper, Typography } from '@mui/material'
|
||||
import { Box, Divider, LinearProgress, Link, Paper, Typography } from '@mui/material'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import type { Match } from '../../domain/match'
|
||||
|
||||
interface BreakdownRowProps {
|
||||
@@ -41,9 +42,10 @@ function BreakdownRow({ label, value, max, description, color = 'primary', modif
|
||||
|
||||
interface Props {
|
||||
match: Match
|
||||
taxCalculatorUrl?: string
|
||||
}
|
||||
|
||||
export function ScoreBreakdownPanel({ match }: Props) {
|
||||
export function ScoreBreakdownPanel({ match, taxCalculatorUrl }: Props) {
|
||||
const sb = match.scoreBreakdown
|
||||
|
||||
const hardColor: 'success' | 'warning' | 'error' =
|
||||
@@ -66,10 +68,30 @@ export function ScoreBreakdownPanel({ match }: Props) {
|
||||
label="Soft Factors (40%)"
|
||||
value={sb.softFactorScore}
|
||||
max={100}
|
||||
description="Prestige, Erreichbarkeit, ESG, Flexibilität"
|
||||
description="Prestige, Erreichbarkeit, ESG, Steuerlast"
|
||||
color={softColor}
|
||||
/>
|
||||
|
||||
{taxCalculatorUrl && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5, p: 1.25, bgcolor: '#f0f9ff', borderRadius: 1, border: '1px solid #bae6fd' }}>
|
||||
<ExternalLink size={13} color="#0369a1" style={{ flexShrink: 0 }} />
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: '#0369a1', fontWeight: 600, display: 'block', lineHeight: 1.2 }}>
|
||||
KI hat Steuerlast bewertet
|
||||
</Typography>
|
||||
<Link
|
||||
href={taxCalculatorUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
sx={{ fontSize: '0.72rem', color: '#0369a1' }}
|
||||
>
|
||||
Steuerrechner Gemeinde öffnen →
|
||||
</Link>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
|
||||
<BreakdownRow
|
||||
|
||||
@@ -1,28 +1,107 @@
|
||||
import { Box } from '@mui/material'
|
||||
import { Box, Chip, Divider, Typography } from '@mui/material'
|
||||
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||
import { UnifiedResultCard } from './UnifiedResultCard'
|
||||
import { SCORE_THEME } from '../shared/scoreTheme'
|
||||
|
||||
interface Props {
|
||||
results: UnifiedMatchResult[]
|
||||
view?: 'list' | 'grid'
|
||||
}
|
||||
|
||||
export function UnifiedResultFeed({ results, view = 'list' }: Props) {
|
||||
if (view === 'grid') {
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 2 }}>
|
||||
{results.map(result => (
|
||||
<UnifiedResultCard key={result.matchId} result={result} view="grid" />
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
const TIER_CONFIG = [
|
||||
{
|
||||
key: 'gold' as const,
|
||||
label: 'Top Matches',
|
||||
sublabel: '90–100 Punkte',
|
||||
filter: (r: UnifiedMatchResult) => r.matchScore >= 90,
|
||||
},
|
||||
{
|
||||
key: 'silver' as const,
|
||||
label: 'Starke Matches',
|
||||
sublabel: '80–89 Punkte',
|
||||
filter: (r: UnifiedMatchResult) => r.matchScore >= 80 && r.matchScore < 90,
|
||||
},
|
||||
{
|
||||
key: 'bronze' as const,
|
||||
label: 'Weitere Treffer',
|
||||
sublabel: 'unter 80 Punkte',
|
||||
filter: (r: UnifiedMatchResult) => r.matchScore < 80,
|
||||
},
|
||||
]
|
||||
|
||||
interface TierHeaderProps {
|
||||
label: string
|
||||
sublabel: string
|
||||
tierKey: 'gold' | 'silver' | 'bronze'
|
||||
count: number
|
||||
isFirst: boolean
|
||||
}
|
||||
|
||||
function TierHeader({ label, sublabel, tierKey, count, isFirst }: TierHeaderProps) {
|
||||
const theme = SCORE_THEME[tierKey]
|
||||
return (
|
||||
<>
|
||||
{results.map(result => (
|
||||
<UnifiedResultCard key={result.matchId} result={result} view="list" />
|
||||
))}
|
||||
</>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: isFirst ? 0 : 3, mb: 1.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 4,
|
||||
height: 28,
|
||||
borderRadius: 2,
|
||||
background: theme.gradient,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.2, color: theme.text === '#7a4f00' ? '#92400e' : theme.text === '#334155' ? '#1e293b' : '#7c3d12' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{sublabel}</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={count}
|
||||
size="small"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: '0.75rem',
|
||||
background: theme.gradient,
|
||||
color: theme.text,
|
||||
border: `1px solid ${theme.border}`,
|
||||
}}
|
||||
/>
|
||||
<Divider sx={{ flex: 1 }} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function UnifiedResultFeed({ results, view = 'list' }: Props) {
|
||||
const tiers = TIER_CONFIG.map(t => ({ ...t, items: results.filter(t.filter) }))
|
||||
.filter(t => t.items.length > 0)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{tiers.map((tier, idx) => (
|
||||
<Box key={tier.key}>
|
||||
<TierHeader
|
||||
label={tier.label}
|
||||
sublabel={tier.sublabel}
|
||||
tierKey={tier.key}
|
||||
count={tier.items.length}
|
||||
isFirst={idx === 0}
|
||||
/>
|
||||
{view === 'grid' ? (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 2 }}>
|
||||
{tier.items.map(result => (
|
||||
<UnifiedResultCard key={result.matchId} result={result} view="grid" />
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{tier.items.map(result => (
|
||||
<UnifiedResultCard key={result.matchId} result={result} view="list" />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ export type ScoreTier = 'gold' | 'silver' | 'bronze'
|
||||
|
||||
export function getScoreTier(score: number): ScoreTier {
|
||||
if (score >= 90) return 'gold'
|
||||
if (score >= 75) return 'silver'
|
||||
if (score >= 80) return 'silver'
|
||||
return 'bronze'
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user