feat: collapsible filter bar, compact property cards, offer wizard field selection
- Properties page: collapsible decision/filter strip (localStorage state), slim single-row Datenpflege status bar (chips + button), 4-column card grid - PropertyIntelligenceCard: reduced image height (160→120px), tighter body padding - OfferWizard (latente Anfragen): new "Felder wählen" step between property selection and PDF preview; uses ReportObjectFieldSelector per property - MockPdfPreview: adds second PDF page showing selected properties side by side with photo, title, location and all chosen hard/soft fact fields - offerWizardStore: adds select_fields step type and fieldSelections state Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { memo } from 'react'
|
||||
import { Box, Button, Chip, LinearProgress, Typography } from '@mui/material'
|
||||
import { LocationPreview } from '../shared/LocationPreview'
|
||||
import { Box, Chip, LinearProgress, Typography } from '@mui/material'
|
||||
import { MapPin, Maximize2, TrendingUp, Calendar } from 'lucide-react'
|
||||
import { getAssetTypeColor, getAssetTypeLabel, getAvailabilityLabel } from './propertyHelpers'
|
||||
import type { Property } from '../../domain/property'
|
||||
|
||||
@@ -9,133 +9,176 @@ interface Props {
|
||||
onSelect: (id: string) => void
|
||||
}
|
||||
|
||||
function availabilityBadgeColor(status: string): string {
|
||||
if (status === 'AVAILABLE_NOW') return '#1a7a4a'
|
||||
if (status === 'AVAILABLE_SOON') return '#d97706'
|
||||
return '#64748b'
|
||||
function availabilityColor(status: string) {
|
||||
if (status === 'AVAILABLE_NOW') return { bg: '#dcfce7', text: '#15803d' }
|
||||
if (status === 'AVAILABLE_SOON') return { bg: '#fef9c3', text: '#a16207' }
|
||||
return { bg: '#f1f5f9', text: '#64748b' }
|
||||
}
|
||||
|
||||
export const PropertyIntelligenceCard = memo(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'
|
||||
const confColor = p.confidenceScore >= 0.75 ? '#15803d' : p.confidenceScore >= 0.55 ? '#d97706' : '#dc2626'
|
||||
const annualRent = Math.round(p.areaSqm * p.rentPricePerSqm / 1000)
|
||||
const avail = availabilityColor(p.availabilityStatus)
|
||||
const assetColor = getAssetTypeColor(p.assetType)
|
||||
const hasImage = !!p.images?.[0]
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={() => onSelect(p.id)}
|
||||
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)',
|
||||
border: '1px solid #e2e8f0',
|
||||
bgcolor: 'white',
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.06)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'box-shadow 0.15s, transform 0.1s',
|
||||
cursor: 'pointer',
|
||||
transition: 'box-shadow 0.15s, transform 0.12s',
|
||||
'&:hover': {
|
||||
boxShadow: '0 6px 24px rgba(0,0,0,0.10)',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
|
||||
transform: 'translateY(-2px)',
|
||||
borderColor: '#cbd5e1',
|
||||
},
|
||||
}}
|
||||
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,
|
||||
}}
|
||||
{/* ── Image / header ── */}
|
||||
<Box sx={{ position: 'relative', height: 120, flexShrink: 0, bgcolor: '#f1f5f9', overflow: 'hidden' }}>
|
||||
{hasImage ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={p.images![0]}
|
||||
alt={p.title}
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%', height: '100%',
|
||||
background: `linear-gradient(135deg, ${assetColor}22 0%, ${assetColor}44 100%)`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: '2.5rem', opacity: 0.3 }}>🏢</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Asset type chip */}
|
||||
<Box sx={{ position: 'absolute', top: 10, right: 44 }}>
|
||||
{/* Overlay gradient */}
|
||||
<Box sx={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(0,0,0,0.45) 0%, transparent 55%)' }} />
|
||||
|
||||
{/* Top badges */}
|
||||
<Box sx={{ position: 'absolute', top: 8, left: 8, display: 'flex', gap: 0.5 }}>
|
||||
<Chip
|
||||
label={getAssetTypeLabel(p.assetType)}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: getAssetTypeColor(p.assetType),
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
fontSize: 10,
|
||||
height: 20,
|
||||
}}
|
||||
sx={{ height: 20, fontSize: '0.65rem', fontWeight: 700, bgcolor: assetColor, color: 'white', borderRadius: 1 }}
|
||||
/>
|
||||
<Chip
|
||||
label={getAvailabilityLabel(p.availabilityStatus)}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.65rem', fontWeight: 700, bgcolor: avail.bg, color: avail.text, borderRadius: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Bottom: annual rent on image */}
|
||||
<Box sx={{ position: 'absolute', bottom: 8, left: 10, right: 10, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
|
||||
<Box>
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.8)', fontSize: '0.65rem', lineHeight: 1 }}>Jahresmiete ca.</Typography>
|
||||
<Typography sx={{ color: 'white', fontWeight: 800, fontSize: '1.05rem', lineHeight: 1.2 }}>
|
||||
CHF {annualRent}k
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.9)', fontSize: '0.72rem', fontWeight: 600 }}>
|
||||
{p.rentPricePerSqm} /m²/J
|
||||
</Typography>
|
||||
</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>
|
||||
{/* ── Card body ── */}
|
||||
<Box sx={{ p: 1.5, display: 'flex', flexDirection: 'column', gap: 1, flex: 1 }}>
|
||||
|
||||
{/* 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²/Jahr`} 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 }} />
|
||||
{/* Title + location */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3, color: '#0f172a', fontSize: '0.875rem' }} noWrap>
|
||||
{p.title}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.3 }}>
|
||||
<MapPin size={11} color="#94a3b8" />
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.72rem' }}>
|
||||
{p.location.city}{p.location.district ? ` · ${p.location.district}` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Key metrics row */}
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Box sx={{ flex: 1, bgcolor: '#f8fafc', borderRadius: 1, px: 1, py: 0.75, textAlign: 'center', border: '1px solid #f1f5f9' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.3, mb: 0.1 }}>
|
||||
<Maximize2 size={10} color="#64748b" />
|
||||
<Typography sx={{ fontSize: '0.62rem', color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.3 }}>Fläche</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem', color: '#0f172a' }}>
|
||||
{p.areaSqm.toLocaleString('de-CH')} m²
|
||||
</Typography>
|
||||
</Box>
|
||||
{p.availabilityDate && (
|
||||
<Box sx={{ flex: 1, bgcolor: '#f8fafc', borderRadius: 1, px: 1, py: 0.75, textAlign: 'center', border: '1px solid #f1f5f9' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.3, mb: 0.1 }}>
|
||||
<Calendar size={10} color="#64748b" />
|
||||
<Typography sx={{ fontSize: '0.62rem', color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.3 }}>Ab</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem', color: '#0f172a' }}>
|
||||
{new Date(p.availabilityDate).toLocaleDateString('de-CH', { month: 'short', year: '2-digit' })}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{p.softFactors?.prestige != null && (
|
||||
<Box sx={{ flex: 1, bgcolor: '#f8fafc', borderRadius: 1, px: 1, py: 0.75, textAlign: 'center', border: '1px solid #f1f5f9' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.3, mb: 0.1 }}>
|
||||
<TrendingUp size={10} color="#64748b" />
|
||||
<Typography sx={{ fontSize: '0.62rem', color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.3 }}>Prestige</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem', color: '#0f172a' }}>
|
||||
{p.softFactors.prestige}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Soft factors */}
|
||||
{/* Soft factor chips */}
|
||||
{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 }} />
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{p.softFactors.publicTransportMinutes != null && (
|
||||
<Chip label={`🚇 ${p.softFactors.publicTransportMinutes} min ÖV`} size="small"
|
||||
sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#f0f9ff', color: '#0369a1' }} />
|
||||
)}
|
||||
{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 }} />
|
||||
{p.hardFacts?.fitOut && (
|
||||
<Chip label={p.hardFacts.fitOut} size="small"
|
||||
sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#f5f3ff', color: '#6d28d9' }} />
|
||||
)}
|
||||
{p.contractDurationMonths && (
|
||||
<Chip label={`${p.contractDurationMonths}M Vertrag`} size="small"
|
||||
sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#f1f5f9', color: '#475569' }} />
|
||||
)}
|
||||
</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>
|
||||
{/* Confidence bar */}
|
||||
<Box sx={{ mt: 'auto' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
|
||||
<Typography sx={{ fontSize: '0.67rem', color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.3 }}>Datenkonfidenz</Typography>
|
||||
<Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: confColor }}>{confPct}%</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={confPct}
|
||||
sx={{
|
||||
height: 5, borderRadius: 3, bgcolor: '#e2e8f0',
|
||||
'& .MuiLinearProgress-bar': { bgcolor: confColor },
|
||||
height: 4, borderRadius: 2, bgcolor: '#f1f5f9',
|
||||
'& .MuiLinearProgress-bar': { bgcolor: confColor, borderRadius: 2 },
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user