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>
This commit is contained in:
Benjamin Sutter
2026-05-20 19:47:59 +02:00
parent 66aabde1dc
commit d902feb6c0
14 changed files with 578 additions and 18 deletions
@@ -25,6 +25,7 @@ import { PropertyMap } from '../shared'
import { NeedMatchCard } from './NeedMatchCard'
import { PropertyMarketSignalsTab } from './PropertyMarketSignalsTab'
import type { Property, PropertyUnit, UnitNeedMatch, UpdatePropertyInput } from '../../domain/property'
import { MockupUnitProvider } from '../../provider/MockupUnitProvider'
import { unitMatchService } from '../../services/unitMatchService'
import type { PropertyNeedMatch } from '../../domain/match'
import { usePropertyById } from '../../hooks/useProperties'
@@ -317,6 +318,17 @@ 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)
@@ -369,6 +381,21 @@ function PreMarketPanel({ p }: { p: Property }) {
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 }} />
@@ -459,6 +486,68 @@ function PreMarketPanel({ p }: { p: Property }) {
</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 }}>