refactor: split PropertyDetailView (998→192 lines) and MatchDetail (464→236 lines)
PropertyDetailView.tsx extracted into 5 focused components: - PropertyDetailHelpers: Field, FieldGrid, SectionTitle, floorLabel - UnitStructurePanel: floor structure with unit matching - PreMarketPanel: schattenmarkt release controls - PropertyDetailOverview: overview tab content - MatchabilityTabPanel: need matches tab MatchDetail.tsx extracted into 2 focused components: - MatchDetailHero: image/map, header, key facts strip - MatchDetailPropertySections: Preis/Hauptangaben/Eigenschaften/etc. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
import { useState } from 'react'
|
||||
import { Box, Chip, CircularProgress, Divider, Switch, TextField, Typography } from '@mui/material'
|
||||
import { Clock, Layers, ShieldCheck, Target, TrendingUp, Users, Zap } from 'lucide-react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import type { Property } from '../../domain/property'
|
||||
import { MockupUnitProvider } from '../../provider/MockupUnitProvider'
|
||||
import { propertyService } from '../../services/propertyService'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import { floorLabel, SectionTitle } from './PropertyDetailHelpers'
|
||||
|
||||
export const MOCK_TODAY = new Date('2026-05-20')
|
||||
|
||||
export function PreMarketPanel({ p }: { p: Property }) {
|
||||
const [enabled, setEnabled] = useState(p.schattenmarktRelease?.enabled ?? false)
|
||||
const [leadTimeMonths, setLeadTimeMonths] = useState(p.schattenmarktRelease?.leadTimeMonths ?? 6)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [unitStates, setUnitStates] = useState<Record<string, { enabled: boolean; availableFrom: string }>>(() => {
|
||||
const init: Record<string, { enabled: boolean; availableFrom: string }> = {}
|
||||
for (const u of p.units ?? []) {
|
||||
init[u.id] = {
|
||||
enabled: u.schattenmarktRelease?.enabled ?? false,
|
||||
availableFrom: u.schattenmarktRelease?.availableFrom ?? u.leaseEndDate ?? '',
|
||||
}
|
||||
}
|
||||
return init
|
||||
})
|
||||
const [unitSaving, setUnitSaving] = useState<Record<string, boolean>>({})
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useToastStore(s => s.showToast)
|
||||
|
||||
if (p.resultType !== 'VERIFIED_PORTFOLIO') return null
|
||||
if (!p.leaseEndDate && !p.breakoutOptionDate) return null
|
||||
|
||||
const candidates: Date[] = []
|
||||
if (p.leaseEndDate) {
|
||||
const d = new Date(p.leaseEndDate); d.setMonth(d.getMonth() - leadTimeMonths); candidates.push(d)
|
||||
}
|
||||
if (p.breakoutOption && p.breakoutOptionDate) {
|
||||
const d = new Date(p.breakoutOptionDate); d.setMonth(d.getMonth() - leadTimeMonths); candidates.push(d)
|
||||
}
|
||||
const triggerDate = candidates.length ? candidates.reduce((a, b) => (a < b ? a : b)) : null
|
||||
const isActive = triggerDate ? MOCK_TODAY >= triggerDate : false
|
||||
|
||||
const targetDate = (p.breakoutOption && p.breakoutOptionDate)
|
||||
? new Date(p.breakoutOptionDate)
|
||||
: p.leaseEndDate ? new Date(p.leaseEndDate) : null
|
||||
const monthsUntil = targetDate
|
||||
? Math.max(0, Math.round((targetDate.getTime() - MOCK_TODAY.getTime()) / (1000 * 60 * 60 * 24 * 30)))
|
||||
: null
|
||||
|
||||
// Mock demand intelligence (derived deterministically from property characteristics)
|
||||
const demandProfiles = Math.min(14, (p.areaSqm >= 1000 ? 5 : p.areaSqm >= 500 ? 8 : 4) +
|
||||
(['Zürich', 'Basel', 'Bern', 'Zug'].some(c => (p.location?.city ?? '').includes(c)) ? 4 : 1))
|
||||
const highQualityLeads = Math.max(1, Math.floor(demandProfiles * 0.38))
|
||||
|
||||
async function save(nextEnabled: boolean, nextLeadTime: number) {
|
||||
setSaving(true)
|
||||
try {
|
||||
await propertyService.update(p.id, { schattenmarktRelease: { enabled: nextEnabled, leadTimeMonths: nextLeadTime } })
|
||||
await queryClient.invalidateQueries({ queryKey: ['property', p.id] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['properties'] })
|
||||
showToast(nextEnabled ? 'Pre-Market Matching aktiviert.' : 'Pre-Market Matching deaktiviert.', 'success')
|
||||
} catch {
|
||||
showToast('Fehler beim Speichern.', 'error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggle(_: React.ChangeEvent<HTMLInputElement>, checked: boolean) {
|
||||
setEnabled(checked)
|
||||
save(checked, leadTimeMonths)
|
||||
}
|
||||
|
||||
function handleLeadTime(months: number) {
|
||||
setLeadTimeMonths(months)
|
||||
if (enabled) save(enabled, months)
|
||||
}
|
||||
|
||||
async function saveUnit(unitId: string, nextEnabled: boolean, nextDate: string) {
|
||||
setUnitSaving(prev => ({ ...prev, [unitId]: true }))
|
||||
try {
|
||||
await MockupUnitProvider.update(unitId, {
|
||||
schattenmarktRelease: { enabled: nextEnabled, availableFrom: nextDate || undefined },
|
||||
})
|
||||
await queryClient.invalidateQueries({ queryKey: ['property', p.id] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['properties'] })
|
||||
} catch {
|
||||
showToast('Fehler beim Speichern der Einheit.', 'error')
|
||||
} finally {
|
||||
setUnitSaving(prev => ({ ...prev, [unitId]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<SectionTitle title="Pre-Market Matching" />
|
||||
<Box
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: enabled ? '#8b5cf6' : '#e2e8f0',
|
||||
borderRadius: 1.5,
|
||||
p: 1.75,
|
||||
bgcolor: enabled ? '#faf5ff' : 'transparent',
|
||||
transition: 'background 0.2s, border-color 0.2s',
|
||||
}}
|
||||
>
|
||||
{/* Toggle row */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
||||
<Zap size={15} color={enabled ? '#7c3aed' : '#94a3b8'} style={{ marginTop: 2 }} />
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, lineHeight: 1.3 }}>
|
||||
Pre-Market Matching aktivieren
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Kontrollierte Freigabe für qualifizierte Suchanfragen — vor offizieller Insertion
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, ml: 1, flexShrink: 0 }}>
|
||||
{saving && <CircularProgress size={12} sx={{ color: '#7c3aed' }} />}
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={handleToggle}
|
||||
size="small"
|
||||
sx={{
|
||||
'& .MuiSwitch-switchBase.Mui-checked': { color: '#7c3aed' },
|
||||
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#8b5cf6' },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Active: lead time + status + demand intelligence */}
|
||||
{enabled && (
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
{/* Lead time selector */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.25 }}>
|
||||
<Typography variant="caption" sx={{ color: '#374151', fontWeight: 500, minWidth: 72 }}>
|
||||
Lead Time
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{[3, 4, 5, 6, 8, 12].map(m => (
|
||||
<Chip
|
||||
key={m}
|
||||
label={`${m} M`}
|
||||
size="small"
|
||||
onClick={() => handleLeadTime(m)}
|
||||
sx={{
|
||||
height: 20, fontSize: '0.68rem', cursor: 'pointer',
|
||||
bgcolor: leadTimeMonths === m ? '#7c3aed' : '#f1f5f9',
|
||||
color: leadTimeMonths === m ? 'white' : '#374151',
|
||||
'&:hover': { bgcolor: leadTimeMonths === m ? '#6d28d9' : '#e2e8f0' },
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Activation status */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 0.75, p: 1,
|
||||
borderRadius: 1, border: '1px solid',
|
||||
bgcolor: isActive ? '#f0fdf4' : '#fff7ed',
|
||||
borderColor: isActive ? '#bbf7d0' : '#fed7aa',
|
||||
}}
|
||||
>
|
||||
{isActive
|
||||
? <ShieldCheck size={13} color="#166534" style={{ marginTop: 1, flexShrink: 0 }} />
|
||||
: <Clock size={13} color="#92400e" style={{ marginTop: 1, flexShrink: 0 }} />
|
||||
}
|
||||
<Typography variant="caption" sx={{ color: isActive ? '#166534' : '#92400e', fontWeight: 500, lineHeight: 1.4 }}>
|
||||
{isActive
|
||||
? `PRE-MARKET VERIFIED aktiv seit ${triggerDate?.toLocaleDateString('de-CH')} — Fläche im Matching-Feed sichtbar`
|
||||
: triggerDate
|
||||
? `Freigabe startet ${triggerDate.toLocaleDateString('de-CH')} — noch ${monthsUntil} Monate bis Vertragsende`
|
||||
: 'Kein Vertragsende hinterlegt'
|
||||
}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Unit-level release controls */}
|
||||
{(p.units?.length ?? 0) > 0 && (
|
||||
<Box sx={{ mt: 1.25, pt: 1.25, borderTop: '1px solid #e9d5ff' }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#4c1d95', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.63rem', display: 'block', mb: 0.875 }}>
|
||||
Einheiten freigeben
|
||||
</Typography>
|
||||
{p.units!.map(u => {
|
||||
const us = unitStates[u.id] ?? { enabled: false, availableFrom: '' }
|
||||
return (
|
||||
<Box
|
||||
key={u.id}
|
||||
sx={{
|
||||
display: 'grid', gridTemplateColumns: '1fr 140px auto',
|
||||
gap: 1, alignItems: 'center', py: 0.75,
|
||||
borderBottom: '1px solid #f3e8ff',
|
||||
'&:last-child': { borderBottom: 'none' },
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: '#1e293b' }}>
|
||||
{floorLabel(u)}{u.unitLabel ? ` · ${u.unitLabel}` : ''}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.65rem', color: '#64748b' }}>
|
||||
{u.areaSqm.toLocaleString('de-CH')} m²
|
||||
{u.currentTenant ? ` · ${u.currentTenant}` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
<TextField
|
||||
type="date"
|
||||
size="small"
|
||||
value={us.availableFrom}
|
||||
disabled={!us.enabled}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
sx={{ '& .MuiInputBase-input': { fontSize: '0.72rem', py: 0.5 } }}
|
||||
onChange={e => {
|
||||
const next = { ...us, availableFrom: e.target.value }
|
||||
setUnitStates(prev => ({ ...prev, [u.id]: next }))
|
||||
if (us.enabled) saveUnit(u.id, true, e.target.value)
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{unitSaving[u.id] && <CircularProgress size={10} sx={{ color: '#7c3aed' }} />}
|
||||
<Switch
|
||||
size="small"
|
||||
checked={us.enabled}
|
||||
onChange={(_, checked) => {
|
||||
const next = { ...us, enabled: checked }
|
||||
setUnitStates(prev => ({ ...prev, [u.id]: next }))
|
||||
saveUnit(u.id, checked, us.availableFrom)
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiSwitch-switchBase.Mui-checked': { color: '#7c3aed' },
|
||||
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#8b5cf6' },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Demand Intelligence */}
|
||||
<Box sx={{ mt: 1.25, pt: 1.25, borderTop: '1px solid #e9d5ff' }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: '#4c1d95', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.63rem', display: 'block', mb: 0.875 }}>
|
||||
Matching Demand Intelligence
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Users size={11} color="#7c3aed" style={{ flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ color: '#374151', lineHeight: 1.3 }}>
|
||||
<strong>{demandProfiles} aktive Suchprofile</strong> im System erkannt
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Target size={11} color="#7c3aed" style={{ flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ color: '#374151', lineHeight: 1.3 }}>
|
||||
<strong>{highQualityLeads} hochwertige Suchanfragen</strong> mit passendem Flächenbedarf
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<TrendingUp size={11} color="#7c3aed" style={{ flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ color: '#374151', lineHeight: 1.3 }}>
|
||||
Frühzeitige Matchgelegenheit — vor offizieller Vermarktung exklusiv verfügbar
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Inactive: explain value proposition */}
|
||||
{!enabled && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1, lineHeight: 1.5 }}>
|
||||
Wenn aktiviert, erscheint diese Fläche {leadTimeMonths} Monate vor Vertragsende als
|
||||
verifiziertes <strong>PRE-MARKET VERIFIED</strong> Signal für qualifizierte Suchanfragen —
|
||||
kein öffentliches Inserat, kontrolliertes Early Matching.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user