feat: supply card metrics — replace Prestige with Anfragen count
Senior property manager perspective: the three key metrics are Fläche (size), Frei ab (availability date), and Anfragen (active inquiry count). Anfragen box highlights amber when > 0. Rename Datenkonfidenz → Datenvollständigkeit (clearer for the manager). Remove ÖV chip, keep fit-out and contract chips as property specs. Wire inquiryCount from useActiveInquiries in Properties page. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import type { Property } from '../../domain/property'
|
||||
interface Props {
|
||||
property: Property
|
||||
onSelect: (id: string) => void
|
||||
inquiryCount?: number
|
||||
}
|
||||
|
||||
const AVAIL_STYLE: Record<string, { bg: string; text: string }> = {
|
||||
@@ -22,13 +23,19 @@ const FIT_OUT_META: Record<string, { label: string; tip: string }> = {
|
||||
PREMIUM: { label: 'Premium-Ausbau', tip: 'Hochwertig, sofort bezugsfertig.' },
|
||||
}
|
||||
|
||||
export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({ property: p, onSelect }: Props) {
|
||||
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 }
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={() => onSelect(p.id)}
|
||||
@@ -59,10 +66,9 @@ export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Dark overlay for legibility */}
|
||||
<Box sx={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(0,0,0,0.5) 0%, transparent 50%)' }} />
|
||||
|
||||
{/* Top badges — asset type + availability */}
|
||||
{/* 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 }}>
|
||||
@@ -76,7 +82,7 @@ export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Bottom — CHF hero + per-sqm */}
|
||||
{/* 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 }}>
|
||||
@@ -91,7 +97,7 @@ export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.8)', fontSize: '0.72rem', fontWeight: 500 }}>
|
||||
{p.rentPricePerSqm} /m²/J
|
||||
{p.rentPricePerSqm} CHF/m²/J
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -112,33 +118,45 @@ export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Metric boxes */}
|
||||
{/* 3 key metrics: Fläche · Frei ab · Anfragen */}
|
||||
<Box sx={{ display: 'flex', gap: 0.875 }}>
|
||||
{[
|
||||
{ icon: '↗', label: 'FLÄCHE', value: `${p.areaSqm.toLocaleString('de-CH')} m²` },
|
||||
...(p.availabilityDate ? [{ icon: '📅', label: 'AB', value: new Date(p.availabilityDate).toLocaleDateString('de-CH', { month: 'short', year: '2-digit' }) }] : []),
|
||||
...(p.softFactors?.prestige != null ? [{ icon: '↗', label: 'PRESTIGE', value: String(p.softFactors.prestige) }] : []),
|
||||
].map(({ label, value }) => (
|
||||
<Box key={label} sx={{ flex: 1, bgcolor: '#f9f8f6', borderRadius: 1.5, px: 1, py: 0.875, textAlign: 'center', border: `1px solid ${DS_BORDER.muted}` }}>
|
||||
{/* 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 }}>
|
||||
{label}
|
||||
Fläche
|
||||
</Typography>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '0.875rem', color: DS_TEXT.primary }}>
|
||||
{value}
|
||||
{p.areaSqm.toLocaleString('de-CH')} m²
|
||||
</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
|
||||
</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' })
|
||||
: '—'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Chips */}
|
||||
{/* 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}` }}>
|
||||
<Typography sx={{ fontSize: '0.58rem', color: hasInquiries ? '#a16207' : DS_TEXT.muted, textTransform: 'uppercase', letterSpacing: '0.04em', mb: 0.25 }}>
|
||||
Anfragen
|
||||
</Typography>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '0.875rem', color: hasInquiries ? '#a16207' : DS_TEXT.muted }}>
|
||||
{inquiryCount ?? 0}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Property spec chips */}
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{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}` }}
|
||||
/>
|
||||
)}
|
||||
{p.hardFacts?.fitOut && (() => {
|
||||
const meta = FIT_OUT_META[p.hardFacts!.fitOut!] ?? { label: p.hardFacts!.fitOut!, tip: '' }
|
||||
return (
|
||||
@@ -149,16 +167,26 @@ 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}` }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Confidence bar */}
|
||||
{/* Data completeness — tells the manager if their listing is ready to match */}
|
||||
<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' }}>
|
||||
Datenkonfidenz
|
||||
Datenvollständigkeit
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: p.confidenceScore >= 0.75 ? '#b8975a' : DS_TEXT.muted }}>
|
||||
{confPct}%
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { PageHeader } from '../../components/layout'
|
||||
import { ViewToggle } from '../../components/shared'
|
||||
import { useProperties } from '../../hooks/useProperties'
|
||||
import { useActiveInquiries } from '../../hooks/useInquiries'
|
||||
import { PropertyFilterBar, PropertyTable, PropertyDetailView, PropertyIntelligenceCard } from '../../components/supply'
|
||||
import type { PropertyTableFilters } from '../../components/supply'
|
||||
import type { Property } from '../../domain/property'
|
||||
@@ -61,6 +62,15 @@ export default function Properties() {
|
||||
)
|
||||
|
||||
const { data: properties = [], isLoading, isError } = useProperties()
|
||||
const { data: inquiries = [] } = useActiveInquiries()
|
||||
|
||||
const inquiryCountByProperty = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
for (const inq of inquiries) {
|
||||
if (inq.propertyId) map.set(inq.propertyId, (map.get(inq.propertyId) ?? 0) + 1)
|
||||
}
|
||||
return map
|
||||
}, [inquiries])
|
||||
|
||||
const filtered = useMemo(() => applyFilters(properties, filters), [properties, filters])
|
||||
|
||||
@@ -193,7 +203,7 @@ export default function Properties() {
|
||||
{view === 'grid' ? (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 2, p: 2.5 }}>
|
||||
{filtered.map(p => (
|
||||
<PropertyIntelligenceCard key={p.id} property={p} onSelect={setSelectedId} />
|
||||
<PropertyIntelligenceCard key={p.id} property={p} onSelect={setSelectedId} inquiryCount={inquiryCountByProperty.get(p.id) ?? 0} />
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user