Files
property-match/src/pages/demand/PropertyDetail.tsx
T
Benjamin Sutter d902feb6c0 feat: Objekt→Einheit architecture — unit-level pre-market, property detail page, click-through from cards
- Domain: PropertyUnit extended with propertyId + schattenmarktRelease per unit
- Domain: FutureAvailabilityResult carries resolved property + unit
- useSchattenmarktSignals: generates unit-level signals (schattenmarkt-{propId}-{unitId})
- useUnifiedResults: resolves backing property + unit on FUTURE_AVAILABILITY fast path
- IUnitProvider + MockupUnitProvider: first-class unit access and mutation
- matchCardAdapter: maps preMarketUnit, preMarketAllUnits, propertyId, unitId to ViewModel
- IntelligenceMatchCard: PRE-MARKET VERIFIED shows unit info strip + "Zur Einheit →" button
- PropertyDetailView: unit-level toggles + date pickers inside PreMarketPanel
- New page: /demand/property/:propertyId with unit table, status chips, inquiry form
- App.tsx: demand route /demand/property/:propertyId registered
- Mock data: prop-001/007/037 units updated with correct lease dates + unit-level schattenmarktRelease

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 19:47:59 +02:00

289 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
import { useNavigate, useParams, useSearchParams } from 'react-router'
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Divider,
Paper,
TextField,
Typography,
} from '@mui/material'
import {
ArrowLeft,
Building2,
Calendar,
CheckCircle2,
Mail,
MapPin,
ShieldCheck,
Layers,
} from 'lucide-react'
import { usePropertyById } from '../../hooks/useProperties'
import type { PropertyUnit } from '../../domain/property'
const FLOOR_LABEL = (level: number) =>
level === 0 ? 'EG' : level < 0 ? `UG ${Math.abs(level)}` : `${level}.OG`
function UnitStatusChip({ unit }: { unit: PropertyUnit }) {
if (unit.schattenmarktRelease?.enabled) {
return (
<Chip
size="small"
icon={<ShieldCheck size={11} />}
label="PRE-MARKET"
sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #86efac', fontWeight: 700, fontSize: '0.68rem', height: 20 }}
/>
)
}
if (unit.available) {
return <Chip size="small" label="Verfügbar" sx={{ bgcolor: '#eff6ff', color: '#1d4ed8', fontWeight: 600, fontSize: '0.68rem', height: 20 }} />
}
return <Chip size="small" label="Belegt" sx={{ bgcolor: '#f8fafc', color: '#64748b', fontWeight: 500, fontSize: '0.68rem', height: 20 }} />
}
function UnitRow({ unit, highlighted }: { unit: PropertyUnit; highlighted: boolean }) {
const availableFrom = unit.schattenmarktRelease?.availableFrom ?? unit.leaseEndDate
const monthlyRent = unit.rentPricePerSqm ? Math.round(unit.rentPricePerSqm / 12) : undefined
return (
<Box
sx={{
display: 'grid',
gridTemplateColumns: '1fr 80px 110px 120px auto',
gap: 1.5,
alignItems: 'center',
px: 2,
py: 1.5,
borderRadius: 1,
bgcolor: highlighted ? '#faf5ff' : '#f8fafc',
border: highlighted ? '1px solid #e9d5ff' : '1px solid #e2e8f0',
mb: 1,
}}
>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{FLOOR_LABEL(unit.floorLevel)}{unit.unitLabel ? ` · ${unit.unitLabel}` : ''}
</Typography>
{unit.currentTenant && (
<Typography variant="caption" color="text.secondary">{unit.currentTenant}</Typography>
)}
</Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{unit.areaSqm.toLocaleString('de-CH')} m²</Typography>
<Typography variant="body2" color="text.secondary">
{monthlyRent ? `CHF ${monthlyRent}/m²/Mt.` : ''}
</Typography>
<Typography variant="body2" color="text.secondary">
{availableFrom
? new Date(availableFrom).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' })
: ''}
</Typography>
<UnitStatusChip unit={unit} />
</Box>
)
}
export default function PropertyDetail() {
const { propertyId } = useParams<{ propertyId: string }>()
const [searchParams] = useSearchParams()
const highlightUnitId = searchParams.get('unit')
const navigate = useNavigate()
const { data: property, isLoading } = usePropertyById(propertyId ?? '')
const [inquiryName, setInquiryName] = useState('')
const [inquiryText, setInquiryText] = useState('')
const [sent, setSent] = useState(false)
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 8 }}>
<CircularProgress />
</Box>
)
}
if (!property) {
return (
<Box sx={{ p: 4 }}>
<Typography color="text.secondary">Objekt nicht gefunden.</Typography>
</Box>
)
}
const preMarketUnits = (property.units ?? []).filter(u => u.schattenmarktRelease?.enabled)
const otherUnits = (property.units ?? []).filter(u => !u.schattenmarktRelease?.enabled)
const monthlyRentDisplay = Math.round(property.rentPricePerSqm / 12)
function handleSendInquiry() {
if (!inquiryName.trim() || !inquiryText.trim()) return
setSent(true)
}
return (
<Box sx={{ maxWidth: 860, mx: 'auto', p: { xs: 2, md: 3 } }}>
{/* Back */}
<Button
startIcon={<ArrowLeft size={15} />}
onClick={() => navigate(-1)}
size="small"
sx={{ mb: 2, textTransform: 'none', color: 'text.secondary' }}
>
Zurück zu den Ergebnissen
</Button>
{/* Header */}
<Paper sx={{ mb: 2, overflow: 'hidden' }}>
{property.images?.[0] && (
<Box
component="img"
src={property.images[0]}
alt={property.title}
sx={{ width: '100%', height: 220, objectFit: 'cover' }}
/>
)}
<Box sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap', gap: 1 }}>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.5 }}>
<Building2 size={15} color="#7c3aed" />
<Typography variant="caption" sx={{ color: '#7c3aed', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
{property.assetType}
</Typography>
</Box>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 0.5 }}>{property.title}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<MapPin size={13} color="#64748b" />
<Typography variant="body2" color="text.secondary">
{property.address.street} {property.address.houseNumber}, {property.address.postalCode} {property.address.city}
</Typography>
</Box>
</Box>
<Box sx={{ textAlign: 'right' }}>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#1e3a5f' }}>
CHF {monthlyRentDisplay}/m²/Mt.
</Typography>
<Typography variant="caption" color="text.secondary">{property.areaSqm.toLocaleString('de-CH')} m² total</Typography>
</Box>
</Box>
{preMarketUnits.length > 0 && (
<Box sx={{ mt: 2, display: 'flex', alignItems: 'center', gap: 1, bgcolor: '#faf5ff', border: '1px solid #e9d5ff', borderRadius: 1, px: 1.5, py: 1 }}>
<ShieldCheck size={14} color="#7c3aed" />
<Typography sx={{ fontSize: '0.82rem', color: '#4c1d95', fontWeight: 600 }}>
{preMarketUnits.length} Einheit{preMarketUnits.length !== 1 ? 'en' : ''} für Pre-Market freigegeben noch vor offizieller Insertion
</Typography>
</Box>
)}
</Box>
</Paper>
{/* Units */}
{(property.units ?? []).length > 0 && (
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Layers size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Einheiten</Typography>
</Box>
{/* Column headers */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 80px 110px 120px auto', gap: 1.5, px: 2, mb: 0.75 }}>
{['Einheit', 'Fläche', 'Miete', 'Verfügbar ab', 'Status'].map(h => (
<Typography key={h} variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>{h}</Typography>
))}
</Box>
{preMarketUnits.map(u => (
<UnitRow key={u.id} unit={u} highlighted={u.id === highlightUnitId || preMarketUnits.length === 1} />
))}
{otherUnits.map(u => (
<UnitRow key={u.id} unit={u} highlighted={u.id === highlightUnitId} />
))}
</Paper>
)}
{/* Inquiry */}
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Mail size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Verwaltung kontaktieren</Typography>
</Box>
{sent ? (
<Alert
icon={<CheckCircle2 size={18} />}
severity="success"
sx={{ borderRadius: 1 }}
>
Ihre Anfrage wurde übermittelt. Die Verwaltung meldet sich in Kürze.
</Alert>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Ihr Name</Typography>
<TextField
size="small"
fullWidth
placeholder="Max Muster"
value={inquiryName}
onChange={e => setInquiryName(e.target.value)}
/>
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Bezug</Typography>
<TextField
size="small"
fullWidth
value={
highlightUnitId
? (property.units?.find(u => u.id === highlightUnitId)?.unitLabel ?? 'Einheit')
: property.title
}
InputProps={{ readOnly: true }}
sx={{ '& .MuiInputBase-input': { color: 'text.secondary', fontSize: '0.85rem' } }}
/>
</Box>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Ihre Nachricht</Typography>
<TextField
size="small"
fullWidth
multiline
rows={4}
placeholder="Wir interessieren uns für die Fläche und möchten gerne einen Besichtigungstermin vereinbaren..."
value={inquiryText}
onChange={e => setInquiryText(e.target.value)}
/>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Calendar size={12} color="#64748b" />
<Typography variant="caption" color="text.secondary">Antwortzeit: typisch 12 Werktage</Typography>
</Box>
<Button
variant="contained"
size="small"
disabled={!inquiryName.trim() || !inquiryText.trim()}
onClick={handleSendInquiry}
startIcon={<Mail size={14} />}
sx={{ bgcolor: '#7c3aed', '&:hover': { bgcolor: '#6d28d9' }, textTransform: 'none' }}
>
Anfrage senden
</Button>
</Box>
</Box>
)}
</Paper>
<Divider sx={{ my: 2 }} />
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.5 }}>
Diese Fläche ist noch nicht offiziell auf dem Markt. Die Verwaltung hat sie explizit für qualifizierte Suchanfragen freigegeben. Ihre Anfrage wird vertraulich behandelt.
</Typography>
</Box>
)
}