74f9660581
- Extract UnitFieldsEditor (price, availability, Ausbaustandard, Wer-baut-aus, MAB, parking allocation, expected price) — single source for per-unit editing - UnitStructurePanel: always-visible per-unit details strip (fit-out, cost-bearer, parking, expected price) in read mode; pencil opens the shared editor - PreMarketUnitGrid: per-unit details shown + same editor reachable per unit (pencil) so fit-out/price/parking are adjustable right in the release flow - Removes the previous edit-only / fragmented per-unit fields Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
219 lines
9.3 KiB
TypeScript
219 lines
9.3 KiB
TypeScript
import { useState } from 'react'
|
|
import { useQueryClient } from '@tanstack/react-query'
|
|
import { Box, Chip, CircularProgress, Divider, Switch, Typography } from '@mui/material'
|
|
import { Clock, ShieldCheck, Zap } from 'lucide-react'
|
|
import type { Property } from '../../domain/property'
|
|
import { MockupUnitProvider } from '../../provider/MockupUnitProvider'
|
|
import { useUpdateProperty } from '../../hooks/useProperties'
|
|
import { useToastStore } from '../../stores/toastStore'
|
|
import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds'
|
|
import { SectionTitle } from './PropertyDetailHelpers'
|
|
import { PreMarketDemandIntelligence } from './PreMarketDemandIntelligence'
|
|
import { PreMarketUnitGrid, type UnitReleaseState } from './PreMarketUnitGrid'
|
|
|
|
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 [unitStates, setUnitStates] = useState<Record<string, UnitReleaseState>>(() => {
|
|
const init: Record<string, UnitReleaseState> = {}
|
|
for (const u of p.units ?? []) {
|
|
init[u.id] = {
|
|
enabled: u.schattenmarktRelease?.enabled ?? false,
|
|
availableFrom: u.schattenmarktRelease?.availableFrom ?? u.leaseEndDate ?? '',
|
|
anonymous: u.schattenmarktRelease?.anonymous ?? false,
|
|
}
|
|
}
|
|
return init
|
|
})
|
|
const [unitSaving, setUnitSaving] = useState<Record<string, boolean>>({})
|
|
const queryClient = useQueryClient()
|
|
const updateProperty = useUpdateProperty()
|
|
const showToast = useToastStore(s => s.showToast)
|
|
const saving = updateProperty.isPending
|
|
|
|
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
|
|
|
|
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))
|
|
|
|
function save(nextEnabled: boolean, nextLeadTime: number) {
|
|
updateProperty.mutate(
|
|
{ id: p.id, input: { schattenmarktRelease: { enabled: nextEnabled, leadTimeMonths: nextLeadTime } } },
|
|
{
|
|
onSuccess: () => {
|
|
showToast(nextEnabled ? 'Pre-Market Matching aktiviert.' : 'Pre-Market Matching deaktiviert.', 'success')
|
|
},
|
|
onError: () => {
|
|
showToast('Fehler beim Speichern.', 'error')
|
|
},
|
|
},
|
|
)
|
|
}
|
|
|
|
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, nextAnonymous: boolean) {
|
|
setUnitSaving(prev => ({ ...prev, [unitId]: true }))
|
|
try {
|
|
await MockupUnitProvider.update(unitId, {
|
|
schattenmarktRelease: { enabled: nextEnabled, availableFrom: nextDate || undefined, anonymous: nextAnonymous },
|
|
})
|
|
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' : DS_BORDER.default,
|
|
borderRadius: 1.5,
|
|
p: 1.75,
|
|
bgcolor: enabled ? DS_PRE_MARKET.headerBg : '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 ? DS_PRE_MARKET.accent : DS_TEXT.disabled} 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: DS_PRE_MARKET.accent }} />}
|
|
<Switch
|
|
checked={enabled}
|
|
onChange={handleToggle}
|
|
size="small"
|
|
sx={{
|
|
'& .MuiSwitch-switchBase.Mui-checked': { color: DS_PRE_MARKET.accent },
|
|
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#8b5cf6' },
|
|
}}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Active: lead time + status + unit grid + 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 ? DS_PRE_MARKET.accent : DS_SURFACE.slate.bg,
|
|
color: leadTimeMonths === m ? DS_TEXT.inverted : '#374151',
|
|
'&:hover': { bgcolor: leadTimeMonths === m ? DS_PRE_MARKET.accentHover : DS_BORDER.default },
|
|
}}
|
|
/>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Activation status */}
|
|
<Box
|
|
sx={{
|
|
display: 'flex', alignItems: 'flex-start', gap: 0.75, p: 1,
|
|
borderRadius: 1, border: '1px solid',
|
|
bgcolor: isActive ? DS_SURFACE.success.bg : DS_SURFACE.orange.bg,
|
|
borderColor: isActive ? DS_SURFACE.success.border : DS_SURFACE.orange.border,
|
|
}}
|
|
>
|
|
{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>
|
|
|
|
{(p.units?.length ?? 0) > 0 && (
|
|
<PreMarketUnitGrid
|
|
property={p}
|
|
units={p.units!}
|
|
unitStates={unitStates}
|
|
unitSaving={unitSaving}
|
|
setUnitStates={setUnitStates}
|
|
saveUnit={saveUnit}
|
|
/>
|
|
)}
|
|
|
|
<PreMarketDemandIntelligence
|
|
demandProfiles={demandProfiles}
|
|
highQualityLeads={highQualityLeads}
|
|
/>
|
|
</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>
|
|
</>
|
|
)
|
|
}
|