feat: F029 intelligence card system — premium AI-native CRE cards
Gold/Silver/Bronze FIFA-style score badges overlaid on location imagery. List/grid view toggle on Results and Properties pages (persisted in localStorage). IntelligenceMatchCard for match results grid view; PropertyIntelligenceCard for property grid view with warm brown theme. LocationPreview component with Google Maps link. 10 mock properties enriched with Unsplash building images. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import { Alert, Box, Button, Chip, Divider, Typography } from '@mui/material'
|
||||
import { CheckCircle2 } from 'lucide-react'
|
||||
import { LocationPreview } from '../shared/LocationPreview'
|
||||
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
||||
import type { MatchCardViewModel } from './MatchCardViewModel'
|
||||
|
||||
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
|
||||
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
|
||||
EXTERNAL_MARKET: { label: 'Marktinserat', color: '#d97706' },
|
||||
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
|
||||
}
|
||||
|
||||
interface Props {
|
||||
vm: MatchCardViewModel
|
||||
imageUrl?: string
|
||||
lat?: number
|
||||
lng?: number
|
||||
cityLabel?: string
|
||||
}
|
||||
|
||||
export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Props) {
|
||||
const tier = getScoreTier(vm.matchScore)
|
||||
const theme = SCORE_THEME[tier]
|
||||
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
|
||||
const topReason = vm.reasons[0]
|
||||
|
||||
const isFuture = vm.resultType === 'FUTURE_AVAILABILITY'
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
boxShadow: `0 2px 16px rgba(0,0,0,0.07), 0 0 0 1px ${theme.cardBorder}`,
|
||||
border: isFuture ? '2px solid #7c3aed' : `1px solid ${theme.cardBorder}`,
|
||||
background: theme.cardBg,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'box-shadow 0.15s, transform 0.1s',
|
||||
'&:hover': {
|
||||
boxShadow: `0 6px 28px rgba(0,0,0,0.12), 0 0 0 1px ${theme.cardBorder}`,
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Image zone */}
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={175} />
|
||||
|
||||
{/* Score badge — overlaid top-left */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
left: 10,
|
||||
background: theme.gradient,
|
||||
borderRadius: '10px',
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
boxShadow: `0 4px 16px ${theme.glow}`,
|
||||
border: `1px solid ${theme.border}`,
|
||||
minWidth: 52,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<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}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Result type chip — overlaid top-right */}
|
||||
<Box sx={{ position: 'absolute', top: 10, right: 44 }}>
|
||||
<Chip
|
||||
label={rt.label}
|
||||
size="small"
|
||||
sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Card body */}
|
||||
<Box sx={{ p: 2, flex: 1, display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3 }} noWrap>
|
||||
{vm.title}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25 }}>
|
||||
{[vm.locationLabel, vm.availabilityLabel].filter(Boolean).join(' · ')}
|
||||
</Typography>
|
||||
|
||||
{vm.explainabilitySummary && (
|
||||
<>
|
||||
<Divider sx={{ my: 1.25 }} />
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
color: '#1e293b',
|
||||
lineHeight: 1.5,
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{vm.explainabilitySummary}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
|
||||
{topReason && (
|
||||
<>
|
||||
<Divider sx={{ my: 1.25 }} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
||||
<CheckCircle2 size={13} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ color: '#1a7a4a', fontWeight: 600, lineHeight: 1.4 }}>
|
||||
{topReason.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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 => (
|
||||
<Button
|
||||
key={a.id}
|
||||
size="small"
|
||||
variant={a.variant === 'primary' ? 'contained' : 'outlined'}
|
||||
onClick={a.onClick}
|
||||
disabled={a.disabled}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontSize: '0.7rem',
|
||||
py: 0.375,
|
||||
px: 1,
|
||||
...(a.variant === 'primary' && {
|
||||
bgcolor: '#1e3a5f',
|
||||
'&:hover': { bgcolor: '#162d4a' },
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{a.label}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* FUTURE_AVAILABILITY disclaimer */}
|
||||
{vm.disclaimer && (
|
||||
<Alert severity="warning" sx={{ borderRadius: 0, py: 0.25, '& .MuiAlert-message': { fontSize: '0.7rem' } }}>
|
||||
{vm.disclaimer}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +1,29 @@
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { ViewToggle } from '../shared'
|
||||
|
||||
interface Props {
|
||||
total: number
|
||||
verifiedCount: number
|
||||
externalCount: number
|
||||
futureCount: number
|
||||
view?: 'list' | 'grid'
|
||||
onViewChange?: (v: 'list' | 'grid') => void
|
||||
}
|
||||
|
||||
export function ResultFeedHeader({ total, verifiedCount, externalCount, futureCount }: Props) {
|
||||
export function ResultFeedHeader({ total, verifiedCount, externalCount, futureCount, view = 'list', onViewChange }: Props) {
|
||||
return (
|
||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2 }}>
|
||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }} color="text.primary">
|
||||
{total} Treffer gefunden
|
||||
{total} Empfehlungen
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{verifiedCount} Verified · {externalCount} Extern · {futureCount} Signale
|
||||
{verifiedCount} Verifiziert · {externalCount} Extern · {futureCount} Marktsignale
|
||||
</Typography>
|
||||
</Box>
|
||||
{onViewChange && (
|
||||
<ViewToggle view={view} onChange={onViewChange} />
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useNavigate } from 'react-router'
|
||||
import { MatchCardCompact } from '../match-card/MatchCardCompact'
|
||||
import { IntelligenceMatchCard } from '../match-card/IntelligenceMatchCard'
|
||||
import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter'
|
||||
import { useCompareStore } from '../../stores/compareStore'
|
||||
import { useShortlistStore } from '../../stores/shortlistStore'
|
||||
@@ -8,16 +9,17 @@ import type { MatchCardAction } from '../match-card/MatchCardViewModel'
|
||||
|
||||
interface Props {
|
||||
result: UnifiedMatchResult
|
||||
view?: 'list' | 'grid'
|
||||
}
|
||||
|
||||
function getResultTitle(result: UnifiedMatchResult): string {
|
||||
if (result.resultType !== 'FUTURE_AVAILABILITY') {
|
||||
return (result as any).property?.title ?? result.matchId
|
||||
return result.property?.title ?? result.matchId
|
||||
}
|
||||
return (result as any).signal?.companyName ?? result.matchId
|
||||
return result.signal?.companyName ?? result.matchId
|
||||
}
|
||||
|
||||
export function UnifiedResultCard({ result }: Props) {
|
||||
export function UnifiedResultCard({ result, view = 'list' }: Props) {
|
||||
const navigate = useNavigate()
|
||||
const { addToCompare, removeFromCompare, isInCompare, isFull } = useCompareStore()
|
||||
const { openAddDialog } = useShortlistStore()
|
||||
@@ -52,7 +54,7 @@ export function UnifiedResultCard({ result }: Props) {
|
||||
},
|
||||
{
|
||||
id: 'details',
|
||||
label: 'Details',
|
||||
label: 'Details →',
|
||||
actionType: 'OPEN_DETAIL',
|
||||
variant: 'primary',
|
||||
onClick: () => navigate(`/demand/results/${result.matchId}`),
|
||||
@@ -60,5 +62,31 @@ export function UnifiedResultCard({ result }: Props) {
|
||||
]
|
||||
|
||||
const vm = buildMatchCardViewModel(result, actions)
|
||||
|
||||
if (view === 'grid') {
|
||||
const imageUrl = result.resultType !== 'FUTURE_AVAILABILITY'
|
||||
? result.property.images?.[0]
|
||||
: undefined
|
||||
const lat = result.resultType !== 'FUTURE_AVAILABILITY'
|
||||
? result.property.location.coordinates?.lat
|
||||
: undefined
|
||||
const lng = result.resultType !== 'FUTURE_AVAILABILITY'
|
||||
? result.property.location.coordinates?.lng
|
||||
: undefined
|
||||
const cityLabel = result.resultType !== 'FUTURE_AVAILABILITY'
|
||||
? result.property.location.city
|
||||
: result.signal.locationHint ?? undefined
|
||||
|
||||
return (
|
||||
<IntelligenceMatchCard
|
||||
vm={vm}
|
||||
imageUrl={imageUrl}
|
||||
lat={lat}
|
||||
lng={lng}
|
||||
cityLabel={cityLabel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <MatchCardCompact vm={vm} />
|
||||
}
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import { Box } from '@mui/material'
|
||||
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||
import { UnifiedResultCard } from './UnifiedResultCard'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export function UnifiedResultFeed({ results }: Props) {
|
||||
return (
|
||||
<>
|
||||
{results.map(result => (
|
||||
<UnifiedResultCard key={result.matchId} result={result} />
|
||||
<UnifiedResultCard key={result.matchId} result={result} view="list" />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react'
|
||||
import { Box, IconButton, Tooltip, Typography } from '@mui/material'
|
||||
import { MapPin, Map } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
imageUrl?: string
|
||||
lat?: number
|
||||
lng?: number
|
||||
address?: string
|
||||
cityLabel?: string
|
||||
height?: number
|
||||
}
|
||||
|
||||
// TODO: replace placeholder with Google Maps Static API:
|
||||
// https://maps.googleapis.com/maps/api/staticmap?center={lat},{lng}&zoom=15&size=800x400&key=YOUR_KEY
|
||||
|
||||
function buildMapsUrl(lat?: number, lng?: number, address?: string): string {
|
||||
if (lat != null && lng != null) {
|
||||
return `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`
|
||||
}
|
||||
if (address) {
|
||||
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}`
|
||||
}
|
||||
return 'https://www.google.com/maps'
|
||||
}
|
||||
|
||||
export function LocationPreview({ imageUrl, lat, lng, address, cityLabel, height = 180 }: Props) {
|
||||
const [imgError, setImgError] = useState(false)
|
||||
const mapsUrl = buildMapsUrl(lat, lng, address)
|
||||
const showImage = !!imageUrl && !imgError
|
||||
|
||||
return (
|
||||
<Box sx={{ position: 'relative', width: '100%', height, overflow: 'hidden', flexShrink: 0 }}>
|
||||
{showImage ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={cityLabel ?? 'Standort'}
|
||||
onError={() => setImgError(true)}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'linear-gradient(160deg,#dce8f2 0%,#b8cfe0 100%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
<MapPin size={28} color="#7ba3bf" />
|
||||
{cityLabel && (
|
||||
<Typography variant="body2" sx={{ color: '#5a87a3', fontWeight: 600, letterSpacing: 0.5 }}>
|
||||
{cityLabel}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Google Maps button */}
|
||||
<Tooltip title="In Google Maps öffnen" placement="top">
|
||||
<IconButton
|
||||
component="a"
|
||||
href={mapsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="small"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
right: 8,
|
||||
bgcolor: 'rgba(255,255,255,0.88)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
width: 28,
|
||||
height: 28,
|
||||
'&:hover': { bgcolor: 'white' },
|
||||
}}
|
||||
>
|
||||
<Map size={14} color="#1e3a5f" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Box, IconButton, Tooltip } from '@mui/material'
|
||||
import { LayoutGrid, LayoutList } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
view: 'list' | 'grid'
|
||||
onChange: (v: 'list' | 'grid') => void
|
||||
}
|
||||
|
||||
export function ViewToggle({ view, onChange }: Props) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 0.25, border: '1px solid #e2e8f0', borderRadius: 1, overflow: 'hidden' }}>
|
||||
<Tooltip title="Listenansicht">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onChange('list')}
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
width: 32,
|
||||
height: 32,
|
||||
bgcolor: view === 'list' ? '#1e3a5f' : 'transparent',
|
||||
color: view === 'list' ? 'white' : '#64748b',
|
||||
'&:hover': { bgcolor: view === 'list' ? '#162d4a' : '#f1f5f9' },
|
||||
}}
|
||||
>
|
||||
<LayoutList size={15} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Kartenansicht">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onChange('grid')}
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
width: 32,
|
||||
height: 32,
|
||||
bgcolor: view === 'grid' ? '#1e3a5f' : 'transparent',
|
||||
color: view === 'grid' ? 'white' : '#64748b',
|
||||
'&:hover': { bgcolor: view === 'grid' ? '#162d4a' : '#f1f5f9' },
|
||||
}}
|
||||
>
|
||||
<LayoutGrid size={15} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { ViewToggle } from './ViewToggle'
|
||||
export { LocationPreview } from './LocationPreview'
|
||||
export { getScoreTier, SCORE_THEME } from './scoreTheme'
|
||||
export type { ScoreTier } from './scoreTheme'
|
||||
@@ -0,0 +1,37 @@
|
||||
export type ScoreTier = 'gold' | 'silver' | 'bronze'
|
||||
|
||||
export function getScoreTier(score: number): ScoreTier {
|
||||
if (score >= 90) return 'gold'
|
||||
if (score >= 75) return 'silver'
|
||||
return 'bronze'
|
||||
}
|
||||
|
||||
export const SCORE_THEME = {
|
||||
gold: {
|
||||
gradient: 'linear-gradient(135deg,#f8e642 0%,#d4920e 100%)',
|
||||
border: '#c9900c',
|
||||
glow: 'rgba(212,146,14,0.35)',
|
||||
text: '#7a4f00',
|
||||
label: 'Top Match',
|
||||
cardBorder: '#fbbf24',
|
||||
cardBg: 'linear-gradient(160deg,#fffbeb,#fef3c7)',
|
||||
},
|
||||
silver: {
|
||||
gradient: 'linear-gradient(135deg,#f1f5f9 0%,#cbd5e1 100%)',
|
||||
border: '#94a3b8',
|
||||
glow: 'rgba(148,163,184,0.30)',
|
||||
text: '#334155',
|
||||
label: 'Starkes Match',
|
||||
cardBorder: '#cbd5e1',
|
||||
cardBg: 'linear-gradient(160deg,#f8fafc,#f1f5f9)',
|
||||
},
|
||||
bronze: {
|
||||
gradient: 'linear-gradient(135deg,#fde8c8 0%,#d4956a 100%)',
|
||||
border: '#c07a46',
|
||||
glow: 'rgba(192,122,70,0.25)',
|
||||
text: '#7c3d0c',
|
||||
label: 'Gutes Match',
|
||||
cardBorder: '#f5d0a9',
|
||||
cardBg: 'linear-gradient(160deg,#fdf6f0,#fef3e8)',
|
||||
},
|
||||
} as const
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Box, Button, Chip, LinearProgress, Typography } from '@mui/material'
|
||||
import { LocationPreview } from '../shared/LocationPreview'
|
||||
import { getAssetTypeColor, getAssetTypeLabel, getAvailabilityLabel } from './propertyHelpers'
|
||||
import type { Property } from '../../domain/property'
|
||||
|
||||
interface Props {
|
||||
property: Property
|
||||
onSelect: (id: string) => void
|
||||
}
|
||||
|
||||
function availabilityBadgeColor(status: string): string {
|
||||
if (status === 'AVAILABLE_NOW') return '#1a7a4a'
|
||||
if (status === 'AVAILABLE_SOON') return '#d97706'
|
||||
return '#64748b'
|
||||
}
|
||||
|
||||
export function PropertyIntelligenceCard({ property: p, onSelect }: Props) {
|
||||
const confPct = Math.round(p.confidenceScore * 100)
|
||||
const confColor = p.confidenceScore >= 0.75 ? '#1a7a4a' : p.confidenceScore >= 0.55 ? '#d97706' : '#c0392b'
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid #e8d5c4',
|
||||
background: 'linear-gradient(160deg,#fdf8f3,#faf0e6)',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'box-shadow 0.15s, transform 0.1s',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
boxShadow: '0 6px 24px rgba(0,0,0,0.10)',
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
}}
|
||||
onClick={() => onSelect(p.id)}
|
||||
>
|
||||
{/* Image zone */}
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<LocationPreview
|
||||
imageUrl={p.images?.[0]}
|
||||
lat={p.location.coordinates?.lat}
|
||||
lng={p.location.coordinates?.lng}
|
||||
address={`${p.address.street} ${p.address.houseNumber}, ${p.address.city}`}
|
||||
cityLabel={p.location.city}
|
||||
height={175}
|
||||
/>
|
||||
|
||||
{/* Availability badge */}
|
||||
<Box sx={{ position: 'absolute', top: 10, left: 10 }}>
|
||||
<Chip
|
||||
label={getAvailabilityLabel(p.availabilityStatus)}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: availabilityBadgeColor(p.availabilityStatus),
|
||||
color: 'white',
|
||||
fontWeight: 700,
|
||||
fontSize: 10,
|
||||
height: 20,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Asset type chip */}
|
||||
<Box sx={{ position: 'absolute', top: 10, right: 44 }}>
|
||||
<Chip
|
||||
label={getAssetTypeLabel(p.assetType)}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: getAssetTypeColor(p.assetType),
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
fontSize: 10,
|
||||
height: 20,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Card body */}
|
||||
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 0, flex: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3 }} noWrap>
|
||||
{p.title}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25, mb: 1.25 }}>
|
||||
{p.location.city}{p.location.district ? ` · ${p.location.district}` : ''}
|
||||
</Typography>
|
||||
|
||||
{/* Key specs */}
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1.25 }}>
|
||||
<Chip label={`${p.areaSqm} m²`} size="small" sx={{ fontSize: 11, bgcolor: '#f1f5f9', height: 22 }} />
|
||||
<Chip label={`CHF ${p.rentPricePerSqm}/m²`} size="small" sx={{ fontSize: 11, bgcolor: '#f1f5f9', height: 22 }} />
|
||||
{p.contractDurationMonths && (
|
||||
<Chip label={`${p.contractDurationMonths}M Vertrag`} size="small" sx={{ fontSize: 11, bgcolor: '#f1f5f9', height: 22 }} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Soft factors */}
|
||||
{p.softFactors && (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1.25 }}>
|
||||
{p.softFactors.prestige != null && (
|
||||
<Chip label={`Prestige ${p.softFactors.prestige}`} size="small"
|
||||
sx={{ fontSize: 10, bgcolor: 'rgba(30,58,95,0.08)', color: '#1e3a5f', height: 20 }} />
|
||||
)}
|
||||
{p.softFactors.accessibility != null && (
|
||||
<Chip label={`ÖV ${p.softFactors.publicTransportMinutes ?? p.softFactors.accessibility}min`} size="small"
|
||||
sx={{ fontSize: 10, bgcolor: 'rgba(30,58,95,0.08)', color: '#1e3a5f', height: 20 }} />
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Confidence */}
|
||||
<Box sx={{ mt: 'auto', pt: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">Datenkonfidenz</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: confColor }}>{confPct}%</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={confPct}
|
||||
sx={{
|
||||
height: 5, borderRadius: 3, bgcolor: '#e2e8f0',
|
||||
'& .MuiLinearProgress-bar': { bgcolor: confColor },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
onClick={e => { e.stopPropagation(); onSelect(p.id) }}
|
||||
sx={{ mt: 1.25, alignSelf: 'flex-end', textTransform: 'none', fontSize: '0.75rem', color: '#1e3a5f', px: 0 }}
|
||||
>
|
||||
Details anzeigen →
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -15,3 +15,4 @@ export { PropertyFilterBar } from './PropertyFilterBar'
|
||||
export type { PropertyTableFilters } from './PropertyFilterBar'
|
||||
export { PropertyDetailView } from './PropertyDetailView'
|
||||
export { PropertyDetailSkeleton } from './PropertyDetailSkeleton'
|
||||
export { PropertyIntelligenceCard } from './PropertyIntelligenceCard'
|
||||
|
||||
@@ -41,6 +41,7 @@ export const mockProperties: Property[] = [
|
||||
contractDurationMonths: 60,
|
||||
ancillaryCosts: 5.5,
|
||||
riskLevel: RiskLevel.LOW,
|
||||
images: ['https://images.unsplash.com/photo-1486325212027-8081e485255e?w=800&h=400&fit=crop'],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2025-01-10T08:00:00Z',
|
||||
updatedAt: '2025-04-28T10:30:00Z',
|
||||
@@ -79,6 +80,7 @@ export const mockProperties: Property[] = [
|
||||
contractDurationMonths: 36,
|
||||
ancillaryCosts: 3.0,
|
||||
riskLevel: RiskLevel.LOW,
|
||||
images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2024-11-20T09:00:00Z',
|
||||
updatedAt: '2025-05-05T11:00:00Z',
|
||||
@@ -119,6 +121,7 @@ export const mockProperties: Property[] = [
|
||||
contractDurationMonths: 48,
|
||||
ancillaryCosts: 5.0,
|
||||
riskLevel: RiskLevel.LOW,
|
||||
images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2025-02-01T09:00:00Z',
|
||||
updatedAt: '2025-05-01T08:00:00Z',
|
||||
@@ -158,6 +161,7 @@ export const mockProperties: Property[] = [
|
||||
contractDurationMonths: 48,
|
||||
ancillaryCosts: 4.5,
|
||||
riskLevel: RiskLevel.LOW,
|
||||
images: ['https://images.unsplash.com/photo-1481277542470-605612bd2d61?w=800&h=400&fit=crop'],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2025-01-20T10:00:00Z',
|
||||
updatedAt: '2025-04-30T09:00:00Z',
|
||||
@@ -196,6 +200,7 @@ export const mockProperties: Property[] = [
|
||||
contractDurationMonths: 60,
|
||||
ancillaryCosts: 2.8,
|
||||
riskLevel: RiskLevel.LOW,
|
||||
images: ['https://images.unsplash.com/photo-1566438480900-0b5b967f4a25?w=800&h=400&fit=crop'],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2024-12-10T08:00:00Z',
|
||||
updatedAt: '2025-05-02T10:00:00Z',
|
||||
@@ -234,6 +239,7 @@ export const mockProperties: Property[] = [
|
||||
contractDurationMonths: 60,
|
||||
ancillaryCosts: 8.0,
|
||||
riskLevel: RiskLevel.LOW,
|
||||
images: ['https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop'],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2025-01-05T10:00:00Z',
|
||||
updatedAt: '2025-05-06T11:00:00Z',
|
||||
@@ -272,6 +278,7 @@ export const mockProperties: Property[] = [
|
||||
contractDurationMonths: 120,
|
||||
ancillaryCosts: 2.5,
|
||||
riskLevel: RiskLevel.LOW,
|
||||
images: ['https://images.unsplash.com/photo-1581091226825-a6a2a5aee158?w=800&h=400&fit=crop'],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2024-10-15T09:00:00Z',
|
||||
updatedAt: '2025-05-03T10:00:00Z',
|
||||
@@ -311,6 +318,7 @@ export const mockProperties: Property[] = [
|
||||
contractDurationMonths: 36,
|
||||
ancillaryCosts: 6.0,
|
||||
riskLevel: RiskLevel.LOW,
|
||||
images: ['https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=800&h=400&fit=crop'],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2025-02-10T11:00:00Z',
|
||||
updatedAt: '2025-04-29T08:00:00Z',
|
||||
@@ -350,6 +358,7 @@ export const mockProperties: Property[] = [
|
||||
contractDurationMonths: 48,
|
||||
ancillaryCosts: 5.0,
|
||||
riskLevel: RiskLevel.LOW,
|
||||
images: ['https://images.unsplash.com/photo-1524758631624-e2822e304c36?w=800&h=400&fit=crop'],
|
||||
organizationId: 'org-wincasa',
|
||||
createdAt: '2025-01-15T09:00:00Z',
|
||||
updatedAt: '2025-04-25T10:00:00Z',
|
||||
|
||||
@@ -36,6 +36,9 @@ export default function Results() {
|
||||
const queryClient = useQueryClient()
|
||||
const [filterSource, setFilterSource] = useState<FilterSource>('ALL')
|
||||
const [sortBy, setSortBy] = useState<SortBy>('score')
|
||||
const [view, setView] = useState<'list' | 'grid'>(() =>
|
||||
(localStorage.getItem('view-results') as 'list' | 'grid') ?? 'list'
|
||||
)
|
||||
|
||||
// When coming from NeedBuilder, invalidate so the freshly created need is included
|
||||
const activeNeedIdFromNav = (location.state as { activeNeedId?: string } | null)?.activeNeedId
|
||||
@@ -86,6 +89,8 @@ export default function Results() {
|
||||
verifiedCount={verifiedCount}
|
||||
externalCount={externalCount}
|
||||
futureCount={futureCount}
|
||||
view={view}
|
||||
onViewChange={v => { setView(v); localStorage.setItem('view-results', v) }}
|
||||
/>
|
||||
|
||||
{!isLoading && results.length > 0 && (
|
||||
@@ -169,7 +174,7 @@ export default function Results() {
|
||||
) : sorted.length === 0 ? (
|
||||
<FeedEmptyState filtered={filterSource !== 'ALL'} />
|
||||
) : (
|
||||
<UnifiedResultFeed results={sorted} />
|
||||
<UnifiedResultFeed results={sorted} view={view} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -3,8 +3,9 @@ import { Box, Drawer } from '@mui/material'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { PageHeader } from '../../components/layout'
|
||||
import { DecisionContextPanel } from '../../components/ui'
|
||||
import { ViewToggle } from '../../components/shared'
|
||||
import { useProperties } from '../../hooks/useProperties'
|
||||
import { PropertyFilterBar, PropertyTable, PropertyDetailView } from '../../components/supply'
|
||||
import { PropertyFilterBar, PropertyTable, PropertyDetailView, PropertyIntelligenceCard } from '../../components/supply'
|
||||
import type { PropertyTableFilters } from '../../components/supply'
|
||||
import type { Property } from '../../domain/property'
|
||||
|
||||
@@ -50,6 +51,9 @@ export default function Properties() {
|
||||
const navigate = useNavigate()
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [filters, setFilters] = useState<PropertyTableFilters>({})
|
||||
const [view, setView] = useState<'list' | 'grid'>(() =>
|
||||
(localStorage.getItem('view-properties') as 'list' | 'grid') ?? 'list'
|
||||
)
|
||||
|
||||
const { data: properties = [], isLoading, isError } = useProperties()
|
||||
const filtered = applyFilters(properties, filters)
|
||||
@@ -65,7 +69,6 @@ export default function Properties() {
|
||||
p => p.dataQuality.freshness === 'STALE' || p.dataQuality.freshness === 'OUTDATED'
|
||||
)
|
||||
|
||||
// Unique missing fields across all objects
|
||||
const allMissingFields = [...new Set(
|
||||
properties.flatMap(p => p.dataQuality.missingCriticalFields)
|
||||
)].slice(0, 4)
|
||||
@@ -75,6 +78,12 @@ export default function Properties() {
|
||||
<PageHeader
|
||||
title="Objektverwaltung"
|
||||
subtitle={`${filtered.length} von ${properties.length} Objekten`}
|
||||
secondaryActions={
|
||||
<ViewToggle
|
||||
view={view}
|
||||
onChange={v => { setView(v); localStorage.setItem('view-properties', v) }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{!isLoading && properties.length > 0 && (
|
||||
@@ -123,6 +132,13 @@ export default function Properties() {
|
||||
<PropertyFilterBar filters={filters} onFiltersChange={setFilters} />
|
||||
|
||||
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||||
{view === 'grid' ? (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 2, p: 2 }}>
|
||||
{filtered.map(p => (
|
||||
<PropertyIntelligenceCard key={p.id} property={p} onSelect={setSelectedId} />
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<PropertyTable
|
||||
properties={filtered}
|
||||
isLoading={isLoading}
|
||||
@@ -132,6 +148,7 @@ export default function Properties() {
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Drawer
|
||||
|
||||
Reference in New Issue
Block a user