refactor: PropertyIntelligenceCard — property manager perspective

Primary element: occupancy status pill (Vermietet/Bald frei/Verfügbar)
with semantic color — this is what drives every manager action.

Metrics: Fläche · Miete CHF/m²/J · Anfragen (not annual total)
- CHF/m²/J is the professional pricing metric, not the portal total
- Removed price overlay from image — clean photo, no text on top
- Image reduced to 150px (photo as context, not hero)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-06-09 22:39:25 +02:00
parent e6c20958a5
commit 5c886193d5
@@ -1,7 +1,7 @@
import { memo } from 'react'
import { Box, Chip, LinearProgress, Tooltip, Typography } from '@mui/material'
import { MapPin } from 'lucide-react'
import { getAssetTypeColor, getAssetTypeLabel, getAvailabilityLabel } from './propertyHelpers'
import { getAssetTypeColor, getAssetTypeLabel } from './propertyHelpers'
import { DS_TEXT, DS_BORDER } from '../../lib/ds'
import type { Property } from '../../domain/property'
@@ -11,10 +11,37 @@ interface Props {
inquiryCount?: number
}
const AVAIL_STYLE: Record<string, { bg: string; text: string }> = {
AVAILABLE_NOW: { bg: '#dcfce7', text: '#1d6342' },
AVAILABLE_SOON: { bg: '#fef9c3', text: '#a16207' },
}
// Occupancy status — the property manager's primary lens
const STATUS_CONFIG = {
AVAILABLE_NOW: {
dot: '#b91c1c',
bg: '#fef2f2',
border:'#fecaca',
text: '#b91c1c',
label: 'Verfügbar',
},
AVAILABLE_SOON: {
dot: '#a16207',
bg: '#fffbeb',
border:'#fde68a',
text: '#a16207',
label: 'Bald frei',
},
OCCUPIED: {
dot: '#1d6342',
bg: '#f0fdf4',
border:'#bbf7d0',
text: '#1d6342',
label: 'Vermietet',
},
FUTURE_SIGNAL: {
dot: '#5b3f8a',
bg: '#faf7ff',
border:'#e9d5ff',
text: '#5b3f8a',
label: 'Geplante Verfügbarkeit',
},
} as const
const FIT_OUT_META: Record<string, { label: string; tip: string }> = {
SHELL: { label: 'Rohbau', tip: 'Keine Einbauten — volle Ausbauinvestition.' },
@@ -25,16 +52,16 @@ const FIT_OUT_META: Record<string, { label: string; tip: string }> = {
export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({ property: p, onSelect, inquiryCount }: Props) {
const confPct = Math.round(p.confidenceScore * 100)
const annualRent = Math.round(p.areaSqm * p.rentPricePerSqm / 1000)
const availStyle = AVAIL_STYLE[p.availabilityStatus] ?? { bg: '#f4f3f0', text: '#9a9a9a' }
const assetColor = getAssetTypeColor(p.assetType)
const hasImage = !!p.images?.[0]
// Inquiry count style — highlight if there's active interest
const hasInquiries = (inquiryCount ?? 0) > 0
const inquiryStyle = hasInquiries
? { bg: '#fffbeb', text: '#a16207', border: '#fde68a' }
: { bg: '#f9f8f6', text: '#9a9a9a', border: DS_BORDER.muted }
const statusKey = p.availabilityStatus as keyof typeof STATUS_CONFIG
const status = STATUS_CONFIG[statusKey] ?? STATUS_CONFIG.OCCUPIED
const availDate = p.availabilityDate
? new Date(p.availabilityDate).toLocaleDateString('de-CH', { month: 'short', year: '2-digit' })
: null
return (
<Box
@@ -51,8 +78,8 @@ export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({
'&:hover': { borderColor: '#c8c7c4' },
}}
>
{/* ── Image ── */}
<Box sx={{ position: 'relative', height: 160, flexShrink: 0, bgcolor: '#f4f3f0', overflow: 'hidden' }}>
{/* ── Image — clean, no text overlay ── */}
<Box sx={{ position: 'relative', height: 150, flexShrink: 0, bgcolor: '#f4f3f0', overflow: 'hidden' }}>
{hasImage ? (
<Box component="img" src={p.images![0]} alt={p.title}
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
@@ -66,44 +93,29 @@ export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({
</Box>
)}
<Box sx={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(0,0,0,0.5) 0%, transparent 50%)' }} />
{/* Badges: asset type + availability */}
<Box sx={{ position: 'absolute', top: 10, left: 10, display: 'flex', gap: 0.5 }}>
<Box sx={{ bgcolor: 'rgba(0,0,0,0.55)', borderRadius: '5px', px: 0.75, py: 0.25 }}>
{/* Single neutral asset-type label */}
<Box sx={{ position: 'absolute', top: 10, left: 10, bgcolor: 'rgba(0,0,0,0.50)', borderRadius: '5px', px: 0.75, py: 0.25 }}>
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: 'white', lineHeight: 1.3 }}>
{getAssetTypeLabel(p.assetType)}
</Typography>
</Box>
<Box sx={{ bgcolor: availStyle.bg, borderRadius: '5px', px: 0.75, py: 0.25 }}>
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: availStyle.text, lineHeight: 1.3 }}>
{getAvailabilityLabel(p.availabilityStatus)}
</Typography>
</Box>
</Box>
{/* Bottom — annual rent hero */}
<Box sx={{ position: 'absolute', bottom: 10, left: 12, right: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
<Box>
<Typography sx={{ color: 'rgba(255,255,255,0.75)', fontSize: '0.62rem', lineHeight: 1 }}>
Jahresmiete ca.
</Typography>
<Typography sx={{
fontFamily: '"DM Serif Display", serif',
fontWeight: 400, fontSize: '1.375rem',
color: 'white', lineHeight: 1.15,
}}>
CHF {annualRent}k
</Typography>
</Box>
<Typography sx={{ color: 'rgba(255,255,255,0.8)', fontSize: '0.72rem', fontWeight: 500 }}>
{p.rentPricePerSqm} CHF/m²/J
</Typography>
</Box>
</Box>
{/* ── Body ── */}
<Box sx={{ p: 1.75, display: 'flex', flexDirection: 'column', gap: 1.25, flex: 1 }}>
<Box sx={{ px: 1.75, pt: 1.5, pb: 1.75, display: 'flex', flexDirection: 'column', gap: 1.125, flex: 1 }}>
{/* ── Occupancy status — primary decision indicator ── */}
<Box sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.75,
bgcolor: status.bg, border: `1px solid ${status.border}`,
borderRadius: 1.5, px: 1, py: 0.5, alignSelf: 'flex-start',
}}>
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: status.dot, flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.75rem', fontWeight: 600, color: status.text, lineHeight: 1 }}>
{status.label}
{p.availabilityStatus === 'AVAILABLE_SOON' && availDate ? ` · ab ${availDate}` : ''}
</Typography>
</Box>
{/* Title + location */}
<Box>
@@ -118,9 +130,8 @@ export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({
</Box>
</Box>
{/* 3 key metrics: Fläche · Frei ab · Anfragen */}
{/* 3 key metrics: Fläche · Miete/m² · Anfragen */}
<Box sx={{ display: 'flex', gap: 0.875 }}>
{/* Fläche */}
<Box sx={{ flex: 1, bgcolor: '#f9f8f6', borderRadius: 1.5, px: 1, py: 0.875, textAlign: 'center', border: `1px solid ${DS_BORDER.muted}` }}>
<Typography sx={{ fontSize: '0.58rem', color: DS_TEXT.muted, textTransform: 'uppercase', letterSpacing: '0.04em', mb: 0.25 }}>
Fläche
@@ -130,21 +141,21 @@ export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({
</Typography>
</Box>
{/* Frei ab */}
<Box sx={{ flex: 1, bgcolor: '#f9f8f6', borderRadius: 1.5, px: 1, py: 0.875, textAlign: 'center', border: `1px solid ${DS_BORDER.muted}` }}>
<Typography sx={{ fontSize: '0.58rem', color: DS_TEXT.muted, textTransform: 'uppercase', letterSpacing: '0.04em', mb: 0.25 }}>
Frei ab
Miete
</Typography>
<Typography sx={{ fontWeight: 700, fontSize: '0.875rem', color: DS_TEXT.primary }}>
{p.availabilityDate
? new Date(p.availabilityDate).toLocaleDateString('de-CH', { month: 'short', year: '2-digit' })
: '—'}
{p.rentPricePerSqm} <Typography component="span" sx={{ fontSize: '0.65rem', fontWeight: 400, color: DS_TEXT.muted }}>CHF/m²/J</Typography>
</Typography>
</Box>
{/* Anfragen */}
<Tooltip title={hasInquiries ? `${inquiryCount} aktive Anfragen` : 'Noch keine Anfragen'} placement="top" arrow>
<Box sx={{ flex: 1, bgcolor: inquiryStyle.bg, borderRadius: 1.5, px: 1, py: 0.875, textAlign: 'center', border: `1px solid ${inquiryStyle.border}` }}>
<Box sx={{
flex: 1, borderRadius: 1.5, px: 1, py: 0.875, textAlign: 'center',
bgcolor: hasInquiries ? '#fffbeb' : '#f9f8f6',
border: `1px solid ${hasInquiries ? '#fde68a' : DS_BORDER.muted}`,
}}>
<Typography sx={{ fontSize: '0.58rem', color: hasInquiries ? '#a16207' : DS_TEXT.muted, textTransform: 'uppercase', letterSpacing: '0.04em', mb: 0.25 }}>
Anfragen
</Typography>
@@ -167,22 +178,16 @@ export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({
)
})()}
{p.contractDurationMonths && (
<Chip
label={`${p.contractDurationMonths}M Vertrag`}
size="small"
sx={{ height: 22, fontSize: '0.7rem', bgcolor: '#f4f3f0', color: DS_TEXT.secondary, border: `1px solid ${DS_BORDER.default}` }}
/>
<Chip label={`${p.contractDurationMonths}M Vertrag`} size="small"
sx={{ height: 22, fontSize: '0.7rem', bgcolor: '#f4f3f0', color: DS_TEXT.secondary, border: `1px solid ${DS_BORDER.default}` }} />
)}
{p.softFactors?.publicTransportMinutes != null && (
<Chip
label={`${p.softFactors.publicTransportMinutes} min ÖV`}
size="small"
sx={{ height: 22, fontSize: '0.7rem', bgcolor: '#f4f3f0', color: DS_TEXT.secondary, border: `1px solid ${DS_BORDER.default}` }}
/>
<Chip label={`${p.softFactors.publicTransportMinutes} min ÖV`} size="small"
sx={{ height: 22, fontSize: '0.7rem', bgcolor: '#f4f3f0', color: DS_TEXT.secondary, border: `1px solid ${DS_BORDER.default}` }} />
)}
</Box>
{/* Data completeness — tells the manager if their listing is ready to match */}
{/* Data completeness — tells manager if listing is visible to matching */}
<Box sx={{ mt: 'auto' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography sx={{ fontSize: '0.62rem', color: DS_TEXT.disabled, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
@@ -192,14 +197,9 @@ export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({
{confPct}%
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={confPct}
sx={{
height: 3, borderRadius: 2, bgcolor: '#f4f3f0',
'& .MuiLinearProgress-bar': { bgcolor: '#152642', borderRadius: 2 },
}}
/>
<LinearProgress variant="determinate" value={confPct}
sx={{ height: 3, borderRadius: 2, bgcolor: '#f4f3f0',
'& .MuiLinearProgress-bar': { bgcolor: '#152642', borderRadius: 2 } }} />
</Box>
</Box>
</Box>