Files
property-match/src/pages/demand/PropertyDetail.tsx
T
Benjamin Sutter 27c53f3af1 feat: score transparency on all cards + budget parser fix
- Single scoring system: MockupNeedProvider rewrites matches via calculateScore
  exclusively — allFactors always populated, no more dual-system (computeScore
  removed), weights from weightingProfile reflected in every breakdown
- ScoreInlineBreakdown: new component shows hard/soft criteria with importance
  labels (Entscheidend/Sehr wichtig/…) and formula on compact + expanded cards
- MatchCardAdapter: passes scoreBreakdown + allFactors to ViewModel
- MatchDetail: 'Zukunftssignal' label replaced with 'Future Availability'
- aiService budget parser: values < 100 treated as monthly and multiplied by 12
  to produce correct annual CHF/m²/year value — fixes 0-result searches

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 20:41:46 +02:00

566 lines
23 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,
Clock,
ExternalLink,
Info,
Layers,
Mail,
MapPin,
ShieldCheck,
Tag,
Train,
TrendingUp,
} from 'lucide-react'
import { usePropertyById } from '../../hooks/useProperties'
import type { PropertyUnit } from '../../domain/property'
// ── Helpers ───────────────────────────────────────────────────────────────────
const FLOOR_LABEL = (level: number) =>
level === 0 ? 'EG' : level < 0 ? `UG ${Math.abs(level)}` : `${level}.OG`
const ASSET_LABELS: Record<string, string> = {
OFFICE: 'Büro', LOGISTICS: 'Lager / Logistik', RETAIL: 'Retail / Laden',
PRODUCTION: 'Produktion', MIXED: 'Gewerbe (gemischt)',
}
const RISK_LABELS: Record<string, string> = {
LOW: 'Niedrig', MEDIUM: 'Mittel', HIGH: 'Hoch',
}
const SOURCE_LABELS: Record<string, string> = {
ERP_IMPORT: 'ERP-Import (intern)',
IMMOSCOUT_SCRAPE: 'ImmoScout24',
HOMEGATE_SCRAPE: 'Homegate',
MATCHOFFICE_SCRAPE: 'MatchOffice',
NEWHOME_SCRAPE: 'newhome.ch',
AI_SIGNAL: 'KI-Signal',
MANUAL: 'Manuell erfasst',
}
const PASSERBY_LABELS: Record<string, string> = {
LOW: 'Gering', MEDIUM: 'Mittel', HIGH: 'Hoch', VERY_HIGH: 'Sehr hoch',
}
// ── Sub-components ────────────────────────────────────────────────────────────
function KeyFactRow({ label, value }: { label: string; value?: string | null }) {
if (!value) return null
return (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', py: 0.875, borderBottom: '1px solid #f1f5f9', '&:last-of-type': { borderBottom: 0 } }}>
<Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0, mr: 2 }}>{label}</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'right' }}>{value}</Typography>
</Box>
)
}
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>
)
}
// ── Page ──────────────────────────────────────────────────────────────────────
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 flexibleUnits = (property.units ?? []).filter(u => u.isFlexible && u.minLettableSqm !== undefined)
const monthlyPerSqm = Math.round(property.rentPricePerSqm / 12)
const totalMonthly = Math.round(property.areaSqm * property.rentPricePerSqm / 12)
const minLettable = property.areaSqmMin
?? (flexibleUnits.length > 0
? Math.min(...flexibleUnits.map(u => u.minLettableSqm!))
: undefined)
const sourceLabel = property.sourceLabel
?? property.sourceMeta?.sourceLabel
?? SOURCE_LABELS[property.sourceType]
?? property.sourceType
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 }}>
{ASSET_LABELS[property.assetType] ?? 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 {monthlyPerSqm.toLocaleString('de-CH')}/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>
{/* ── Preis ── */}
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Tag size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Preis</Typography>
</Box>
<KeyFactRow
label="Monatliche Miete"
value={`CHF ${totalMonthly.toLocaleString('de-CH')}.`}
/>
<KeyFactRow
label="Pro m²/Monat"
value={`CHF ${monthlyPerSqm.toLocaleString('de-CH')}.`}
/>
<KeyFactRow
label="Pro m²/Jahr"
value={`CHF ${property.rentPricePerSqm.toLocaleString('de-CH')}.`}
/>
{property.ancillaryCosts != null && (
<KeyFactRow
label="Nebenkosten"
value={`CHF ${Math.round(property.areaSqm * property.ancillaryCosts / 12).toLocaleString('de-CH')}/Mt. (CHF ${property.ancillaryCosts}/m²/a)`}
/>
)}
</Paper>
{/* ── Hauptangaben ── */}
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Info size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Hauptangaben</Typography>
</Box>
<KeyFactRow
label="Verfügbarkeit"
value={
property.availabilityDate
? new Date(property.availabilityDate).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })
: 'Auf Anfrage'
}
/>
<KeyFactRow label="Objekttyp" value={ASSET_LABELS[property.assetType] ?? property.assetType} />
<KeyFactRow label="Nutzfläche" value={`${property.areaSqm.toLocaleString('de-CH')}`} />
{minLettable != null && (
<KeyFactRow label="Mindestnutzfläche" value={`${minLettable.toLocaleString('de-CH')}`} />
)}
{property.contractDurationMonths != null && (
<KeyFactRow label="Mietdauer" value={`${property.contractDurationMonths} Monate`} />
)}
{property.floorLevel != null && (
<KeyFactRow label="Stockwerk" value={FLOOR_LABEL(property.floorLevel)} />
)}
{property.currentTenant && (
<KeyFactRow label="Aktueller Mieter" value={property.currentTenant} />
)}
{property.leaseEndDate && (
<KeyFactRow
label="Mietvertragsende"
value={new Date(property.leaseEndDate).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' })}
/>
)}
{property.breakoutOption && (
<KeyFactRow
label="Break-out Option"
value={
property.breakoutOptionDate
? new Date(property.breakoutOptionDate).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' })
: 'Ja'
}
/>
)}
{property.riskLevel && (
<KeyFactRow label="Risikoeinschätzung" value={RISK_LABELS[property.riskLevel] ?? property.riskLevel} />
)}
{property.expansionPotentialSqm != null && (
<KeyFactRow label="Ausbaupotenzial" value={`+${property.expansionPotentialSqm.toLocaleString('de-CH')}`} />
)}
</Paper>
{/* ── Eigenschaften ── */}
{property.softFactors && (
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<TrendingUp size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Eigenschaften</Typography>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{property.softFactors.publicTransportMinutes != null && (
<Chip
size="small"
icon={<Train size={11} />}
label={`ÖV: ${property.softFactors.publicTransportMinutes} Min. zu Fuss`}
sx={{ bgcolor: '#eff6ff', color: '#1d4ed8', border: '1px solid #bfdbfe', fontWeight: 500 }}
/>
)}
{property.softFactors.parkingSpots != null && property.softFactors.parkingSpots > 0 && (
<Chip
size="small"
label={`${property.softFactors.parkingSpots} Parkplätze`}
sx={{ bgcolor: '#f8fafc', color: '#374151', border: '1px solid #e2e8f0', fontWeight: 500 }}
/>
)}
{property.softFactors.prestige != null && property.softFactors.prestige >= 80 && (
<Chip
size="small"
label="Prestigestandort"
sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }}
/>
)}
{property.softFactors.visibilityScore != null && property.softFactors.visibilityScore >= 80 && (
<Chip
size="small"
label="Hohe Sichtbarkeit"
sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }}
/>
)}
{property.softFactors.passerbyFrequency && (
<Chip
size="small"
label={`Laufkundschaft: ${PASSERBY_LABELS[property.softFactors.passerbyFrequency]}`}
sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #bbf7d0', fontWeight: 500 }}
/>
)}
{property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && (
<Chip
size="small"
label="Hoher Talentzugang"
sx={{ bgcolor: '#faf5ff', color: '#5b21b6', border: '1px solid #e9d5ff', fontWeight: 500 }}
/>
)}
{property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && (
<Chip
size="small"
label={`Talentindex: ${property.softFactors.talentAccess}`}
sx={{ bgcolor: '#faf5ff', color: '#5b21b6', border: '1px solid #e9d5ff', fontWeight: 500 }}
/>
)}
</Box>
</Paper>
)}
{/* ── Wegzeit ── */}
{property.softFactors?.publicTransportMinutes != null && (
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Clock size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Wegzeit</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 40, height: 40, borderRadius: '50%', bgcolor: '#eff6ff', border: '1px solid #bfdbfe', flexShrink: 0 }}>
<Train size={18} color="#1d4ed8" />
</Box>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{property.softFactors.publicTransportMinutes} Min. zu Fuss
</Typography>
<Typography variant="caption" color="text.secondary">
Nächster ÖV-Anschluss {property.location.city}
</Typography>
</Box>
</Box>
{property.softFactors.infrastructureNotes && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1, fontStyle: 'italic' }}>
{property.softFactors.infrastructureNotes}
</Typography>
)}
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Die Zeiten beziehen sich auf die Strecke zu Fuss.
</Typography>
</Paper>
)}
{/* ── Einheiten ── */}
{(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>
<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>
)}
{/* ── Beschreibung ── */}
{property.description && (
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Building2 size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Beschreibung</Typography>
</Box>
<Typography variant="body2" sx={{ lineHeight: 1.75, color: '#374151', whiteSpace: 'pre-wrap' }}>
{property.description}
</Typography>
</Paper>
)}
{/* ── Quelle & Referenz ── */}
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Info size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Quelle & Referenz</Typography>
</Box>
<KeyFactRow label="Datenquelle" value={sourceLabel} />
{property.propertyNumber && (
<KeyFactRow label="Objektnummer" value={property.propertyNumber} />
)}
{property.importedFrom && (
<KeyFactRow label="Importiert aus" value={property.importedFrom} />
)}
{property.dataQuality.lastVerifiedAt && (
<KeyFactRow
label="Zuletzt verifiziert"
value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })}
/>
)}
{property.sourceUrl && (
<Box sx={{ mt: 1.25 }}>
<Button
size="small"
variant="outlined"
endIcon={<ExternalLink size={12} />}
href={property.sourceUrl}
target="_blank"
rel="noopener noreferrer"
sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: '#cbd5e1', color: '#374151' }}
>
Zum Originalinserat
</Button>
</Box>
)}
</Paper>
{/* ── Verwaltung kontaktieren ── */}
<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
}
slotProps={{ input: { 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>
)
}