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:
@@ -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 }}>
|
||||
<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
|
||||
{/* 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>
|
||||
|
||||
{/* ── 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>
|
||||
|
||||
Reference in New Issue
Block a user