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:
Benjamin Sutter
2026-05-18 13:25:27 +02:00
parent 9e7a09f8a2
commit c75d3c8121
13 changed files with 581 additions and 26 deletions
@@ -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>
)
}
+16 -8
View File
@@ -1,21 +1,29 @@
import { Box, Typography } from '@mui/material' import { Box, Typography } from '@mui/material'
import { ViewToggle } from '../shared'
interface Props { interface Props {
total: number total: number
verifiedCount: number verifiedCount: number
externalCount: number externalCount: number
futureCount: 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 ( 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' }}>
<Typography variant="h5" sx={{ fontWeight: 700 }} color="text.primary"> <Box>
{total} Treffer gefunden <Typography variant="h5" sx={{ fontWeight: 700 }} color="text.primary">
</Typography> {total} Empfehlungen
<Typography variant="body2" color="text.secondary"> </Typography>
{verifiedCount} Verified · {externalCount} Extern · {futureCount} Signale <Typography variant="body2" color="text.secondary">
</Typography> {verifiedCount} Verifiziert · {externalCount} Extern · {futureCount} Marktsignale
</Typography>
</Box>
{onViewChange && (
<ViewToggle view={view} onChange={onViewChange} />
)}
</Box> </Box>
) )
} }
+32 -4
View File
@@ -1,5 +1,6 @@
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { MatchCardCompact } from '../match-card/MatchCardCompact' import { MatchCardCompact } from '../match-card/MatchCardCompact'
import { IntelligenceMatchCard } from '../match-card/IntelligenceMatchCard'
import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter' import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter'
import { useCompareStore } from '../../stores/compareStore' import { useCompareStore } from '../../stores/compareStore'
import { useShortlistStore } from '../../stores/shortlistStore' import { useShortlistStore } from '../../stores/shortlistStore'
@@ -8,16 +9,17 @@ import type { MatchCardAction } from '../match-card/MatchCardViewModel'
interface Props { interface Props {
result: UnifiedMatchResult result: UnifiedMatchResult
view?: 'list' | 'grid'
} }
function getResultTitle(result: UnifiedMatchResult): string { function getResultTitle(result: UnifiedMatchResult): string {
if (result.resultType !== 'FUTURE_AVAILABILITY') { 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 navigate = useNavigate()
const { addToCompare, removeFromCompare, isInCompare, isFull } = useCompareStore() const { addToCompare, removeFromCompare, isInCompare, isFull } = useCompareStore()
const { openAddDialog } = useShortlistStore() const { openAddDialog } = useShortlistStore()
@@ -52,7 +54,7 @@ export function UnifiedResultCard({ result }: Props) {
}, },
{ {
id: 'details', id: 'details',
label: 'Details', label: 'Details',
actionType: 'OPEN_DETAIL', actionType: 'OPEN_DETAIL',
variant: 'primary', variant: 'primary',
onClick: () => navigate(`/demand/results/${result.matchId}`), onClick: () => navigate(`/demand/results/${result.matchId}`),
@@ -60,5 +62,31 @@ export function UnifiedResultCard({ result }: Props) {
] ]
const vm = buildMatchCardViewModel(result, actions) 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} /> return <MatchCardCompact vm={vm} />
} }
+14 -2
View File
@@ -1,15 +1,27 @@
import { Box } from '@mui/material'
import type { UnifiedMatchResult } from '../../domain/unifiedResult' import type { UnifiedMatchResult } from '../../domain/unifiedResult'
import { UnifiedResultCard } from './UnifiedResultCard' import { UnifiedResultCard } from './UnifiedResultCard'
interface Props { interface Props {
results: UnifiedMatchResult[] results: UnifiedMatchResult[]
view?: 'list' | 'grid'
} }
export function UnifiedResultFeed({ results }: Props) { 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>
)
}
return ( return (
<> <>
{results.map(result => ( {results.map(result => (
<UnifiedResultCard key={result.matchId} result={result} /> <UnifiedResultCard key={result.matchId} result={result} view="list" />
))} ))}
</> </>
) )
+87
View File
@@ -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>
)
}
+46
View File
@@ -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>
)
}
+4
View File
@@ -0,0 +1,4 @@
export { ViewToggle } from './ViewToggle'
export { LocationPreview } from './LocationPreview'
export { getScoreTier, SCORE_THEME } from './scoreTheme'
export type { ScoreTier } from './scoreTheme'
+37
View File
@@ -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}`} 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>
)
}
+1
View File
@@ -15,3 +15,4 @@ export { PropertyFilterBar } from './PropertyFilterBar'
export type { PropertyTableFilters } from './PropertyFilterBar' export type { PropertyTableFilters } from './PropertyFilterBar'
export { PropertyDetailView } from './PropertyDetailView' export { PropertyDetailView } from './PropertyDetailView'
export { PropertyDetailSkeleton } from './PropertyDetailSkeleton' export { PropertyDetailSkeleton } from './PropertyDetailSkeleton'
export { PropertyIntelligenceCard } from './PropertyIntelligenceCard'
+9
View File
@@ -41,6 +41,7 @@ export const mockProperties: Property[] = [
contractDurationMonths: 60, contractDurationMonths: 60,
ancillaryCosts: 5.5, ancillaryCosts: 5.5,
riskLevel: RiskLevel.LOW, riskLevel: RiskLevel.LOW,
images: ['https://images.unsplash.com/photo-1486325212027-8081e485255e?w=800&h=400&fit=crop'],
organizationId: 'org-wincasa', organizationId: 'org-wincasa',
createdAt: '2025-01-10T08:00:00Z', createdAt: '2025-01-10T08:00:00Z',
updatedAt: '2025-04-28T10:30:00Z', updatedAt: '2025-04-28T10:30:00Z',
@@ -79,6 +80,7 @@ export const mockProperties: Property[] = [
contractDurationMonths: 36, contractDurationMonths: 36,
ancillaryCosts: 3.0, ancillaryCosts: 3.0,
riskLevel: RiskLevel.LOW, riskLevel: RiskLevel.LOW,
images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'],
organizationId: 'org-wincasa', organizationId: 'org-wincasa',
createdAt: '2024-11-20T09:00:00Z', createdAt: '2024-11-20T09:00:00Z',
updatedAt: '2025-05-05T11:00:00Z', updatedAt: '2025-05-05T11:00:00Z',
@@ -119,6 +121,7 @@ export const mockProperties: Property[] = [
contractDurationMonths: 48, contractDurationMonths: 48,
ancillaryCosts: 5.0, ancillaryCosts: 5.0,
riskLevel: RiskLevel.LOW, riskLevel: RiskLevel.LOW,
images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'],
organizationId: 'org-wincasa', organizationId: 'org-wincasa',
createdAt: '2025-02-01T09:00:00Z', createdAt: '2025-02-01T09:00:00Z',
updatedAt: '2025-05-01T08:00:00Z', updatedAt: '2025-05-01T08:00:00Z',
@@ -158,6 +161,7 @@ export const mockProperties: Property[] = [
contractDurationMonths: 48, contractDurationMonths: 48,
ancillaryCosts: 4.5, ancillaryCosts: 4.5,
riskLevel: RiskLevel.LOW, riskLevel: RiskLevel.LOW,
images: ['https://images.unsplash.com/photo-1481277542470-605612bd2d61?w=800&h=400&fit=crop'],
organizationId: 'org-wincasa', organizationId: 'org-wincasa',
createdAt: '2025-01-20T10:00:00Z', createdAt: '2025-01-20T10:00:00Z',
updatedAt: '2025-04-30T09:00:00Z', updatedAt: '2025-04-30T09:00:00Z',
@@ -196,6 +200,7 @@ export const mockProperties: Property[] = [
contractDurationMonths: 60, contractDurationMonths: 60,
ancillaryCosts: 2.8, ancillaryCosts: 2.8,
riskLevel: RiskLevel.LOW, riskLevel: RiskLevel.LOW,
images: ['https://images.unsplash.com/photo-1566438480900-0b5b967f4a25?w=800&h=400&fit=crop'],
organizationId: 'org-wincasa', organizationId: 'org-wincasa',
createdAt: '2024-12-10T08:00:00Z', createdAt: '2024-12-10T08:00:00Z',
updatedAt: '2025-05-02T10:00:00Z', updatedAt: '2025-05-02T10:00:00Z',
@@ -234,6 +239,7 @@ export const mockProperties: Property[] = [
contractDurationMonths: 60, contractDurationMonths: 60,
ancillaryCosts: 8.0, ancillaryCosts: 8.0,
riskLevel: RiskLevel.LOW, riskLevel: RiskLevel.LOW,
images: ['https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop'],
organizationId: 'org-wincasa', organizationId: 'org-wincasa',
createdAt: '2025-01-05T10:00:00Z', createdAt: '2025-01-05T10:00:00Z',
updatedAt: '2025-05-06T11:00:00Z', updatedAt: '2025-05-06T11:00:00Z',
@@ -272,6 +278,7 @@ export const mockProperties: Property[] = [
contractDurationMonths: 120, contractDurationMonths: 120,
ancillaryCosts: 2.5, ancillaryCosts: 2.5,
riskLevel: RiskLevel.LOW, riskLevel: RiskLevel.LOW,
images: ['https://images.unsplash.com/photo-1581091226825-a6a2a5aee158?w=800&h=400&fit=crop'],
organizationId: 'org-wincasa', organizationId: 'org-wincasa',
createdAt: '2024-10-15T09:00:00Z', createdAt: '2024-10-15T09:00:00Z',
updatedAt: '2025-05-03T10:00:00Z', updatedAt: '2025-05-03T10:00:00Z',
@@ -311,6 +318,7 @@ export const mockProperties: Property[] = [
contractDurationMonths: 36, contractDurationMonths: 36,
ancillaryCosts: 6.0, ancillaryCosts: 6.0,
riskLevel: RiskLevel.LOW, riskLevel: RiskLevel.LOW,
images: ['https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=800&h=400&fit=crop'],
organizationId: 'org-wincasa', organizationId: 'org-wincasa',
createdAt: '2025-02-10T11:00:00Z', createdAt: '2025-02-10T11:00:00Z',
updatedAt: '2025-04-29T08:00:00Z', updatedAt: '2025-04-29T08:00:00Z',
@@ -350,6 +358,7 @@ export const mockProperties: Property[] = [
contractDurationMonths: 48, contractDurationMonths: 48,
ancillaryCosts: 5.0, ancillaryCosts: 5.0,
riskLevel: RiskLevel.LOW, riskLevel: RiskLevel.LOW,
images: ['https://images.unsplash.com/photo-1524758631624-e2822e304c36?w=800&h=400&fit=crop'],
organizationId: 'org-wincasa', organizationId: 'org-wincasa',
createdAt: '2025-01-15T09:00:00Z', createdAt: '2025-01-15T09:00:00Z',
updatedAt: '2025-04-25T10:00:00Z', updatedAt: '2025-04-25T10:00:00Z',
+6 -1
View File
@@ -36,6 +36,9 @@ 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 [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 // When coming from NeedBuilder, invalidate so the freshly created need is included
const activeNeedIdFromNav = (location.state as { activeNeedId?: string } | null)?.activeNeedId const activeNeedIdFromNav = (location.state as { activeNeedId?: string } | null)?.activeNeedId
@@ -86,6 +89,8 @@ export default function Results() {
verifiedCount={verifiedCount} verifiedCount={verifiedCount}
externalCount={externalCount} externalCount={externalCount}
futureCount={futureCount} futureCount={futureCount}
view={view}
onViewChange={v => { setView(v); localStorage.setItem('view-results', v) }}
/> />
{!isLoading && results.length > 0 && ( {!isLoading && results.length > 0 && (
@@ -169,7 +174,7 @@ export default function Results() {
) : sorted.length === 0 ? ( ) : sorted.length === 0 ? (
<FeedEmptyState filtered={filterSource !== 'ALL'} /> <FeedEmptyState filtered={filterSource !== 'ALL'} />
) : ( ) : (
<UnifiedResultFeed results={sorted} /> <UnifiedResultFeed results={sorted} view={view} />
)} )}
</Box> </Box>
</Box> </Box>
+28 -11
View File
@@ -3,8 +3,9 @@ import { Box, Drawer } from '@mui/material'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { PageHeader } from '../../components/layout' import { PageHeader } from '../../components/layout'
import { DecisionContextPanel } from '../../components/ui' import { DecisionContextPanel } from '../../components/ui'
import { ViewToggle } from '../../components/shared'
import { useProperties } from '../../hooks/useProperties' 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 { PropertyTableFilters } from '../../components/supply'
import type { Property } from '../../domain/property' import type { Property } from '../../domain/property'
@@ -50,6 +51,9 @@ export default function Properties() {
const navigate = useNavigate() const navigate = useNavigate()
const [selectedId, setSelectedId] = useState<string | null>(null) const [selectedId, setSelectedId] = useState<string | null>(null)
const [filters, setFilters] = useState<PropertyTableFilters>({}) 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 { data: properties = [], isLoading, isError } = useProperties()
const filtered = applyFilters(properties, filters) const filtered = applyFilters(properties, filters)
@@ -65,7 +69,6 @@ export default function Properties() {
p => p.dataQuality.freshness === 'STALE' || p.dataQuality.freshness === 'OUTDATED' p => p.dataQuality.freshness === 'STALE' || p.dataQuality.freshness === 'OUTDATED'
) )
// Unique missing fields across all objects
const allMissingFields = [...new Set( const allMissingFields = [...new Set(
properties.flatMap(p => p.dataQuality.missingCriticalFields) properties.flatMap(p => p.dataQuality.missingCriticalFields)
)].slice(0, 4) )].slice(0, 4)
@@ -75,6 +78,12 @@ export default function Properties() {
<PageHeader <PageHeader
title="Objektverwaltung" title="Objektverwaltung"
subtitle={`${filtered.length} von ${properties.length} Objekten`} subtitle={`${filtered.length} von ${properties.length} Objekten`}
secondaryActions={
<ViewToggle
view={view}
onChange={v => { setView(v); localStorage.setItem('view-properties', v) }}
/>
}
/> />
{!isLoading && properties.length > 0 && ( {!isLoading && properties.length > 0 && (
@@ -123,15 +132,23 @@ export default function Properties() {
<PropertyFilterBar filters={filters} onFiltersChange={setFilters} /> <PropertyFilterBar filters={filters} onFiltersChange={setFilters} />
<Box sx={{ flex: 1, overflowY: 'auto' }}> <Box sx={{ flex: 1, overflowY: 'auto' }}>
<PropertyTable {view === 'grid' ? (
properties={filtered} <Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 2, p: 2 }}>
isLoading={isLoading} {filtered.map(p => (
isError={isError} <PropertyIntelligenceCard key={p.id} property={p} onSelect={setSelectedId} />
selectedId={selectedId} ))}
onSelect={setSelectedId} </Box>
filters={filters} ) : (
onFiltersChange={setFilters} <PropertyTable
/> properties={filtered}
isLoading={isLoading}
isError={isError}
selectedId={selectedId}
onSelect={setSelectedId}
filters={filters}
onFiltersChange={setFilters}
/>
)}
</Box> </Box>
<Drawer <Drawer