Files
property-match/src/pages/supply/Properties.tsx
T
Benjamin Sutter ce67da73b3 perf: memoize expensive list computations + memo on grid/list items
Results.tsx:
- useMemo: filter + sort in one pass (was 7 separate array iterations per render)
- useMemo: platform/maison/future/missingData counts in single for-loop
- Fix: move queryClient.invalidateQueries from render body into useEffect

Properties.tsx:
- useMemo: wrap applyFilters() call (was full copy+sort on every render)
- useMemo: compute matchReady/criticalGaps/lowConfidence/staleOrOutdated/
  allMissingFields in a single for-loop (was 5 separate filter passes)

ReminderFeed.tsx:
- useMemo: wrap applyFilters() call
- useCallback: resetFilters (passed to ReminderEmptyState)

PropertyIntelligenceCard, ReminderListRow:
- React.memo: grid/list items no longer re-render when unrelated parent
  state changes (e.g. selectedId, filter UI state)

tsc --noEmit passes with zero errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:53:03 +02:00

184 lines
6.9 KiB
TypeScript

import { useState, useMemo } from 'react'
import { Box, Drawer, useMediaQuery, useTheme } from '@mui/material'
import { useNavigate } from 'react-router'
import { PageHeader } from '../../components/layout'
import { DecisionContextPanel } from '../../components/ui'
import { ViewToggle } from '../../components/shared'
import { useProperties } from '../../hooks/useProperties'
import { PropertyFilterBar, PropertyTable, PropertyDetailView, PropertyIntelligenceCard } from '../../components/supply'
import type { PropertyTableFilters } from '../../components/supply'
import type { Property } from '../../domain/property'
function applyFilters(properties: Property[], filters: PropertyTableFilters): Property[] {
let result = [...properties]
if (filters.search) {
const q = filters.search.toLowerCase()
result = result.filter(
p =>
p.title.toLowerCase().includes(q) ||
p.location.city.toLowerCase().includes(q) ||
p.address.street.toLowerCase().includes(q),
)
}
if (filters.assetTypes && filters.assetTypes.length > 0) {
result = result.filter(p => filters.assetTypes!.includes(p.assetType))
}
if (filters.availabilityStatus) {
result = result.filter(p => p.availabilityStatus === filters.availabilityStatus)
}
if (filters.sortBy) {
const dir = filters.sortDir === 'asc' ? 1 : -1
result.sort((a, b) => {
switch (filters.sortBy) {
case 'dataQuality': return dir * (a.dataQuality.score - b.dataQuality.score)
case 'area': return dir * (a.areaSqm - b.areaSqm)
case 'rent': return dir * (a.rentPricePerSqm - b.rentPricePerSqm)
case 'confidence': return dir * (a.confidenceScore - b.confidenceScore)
case 'availability': return dir * a.availabilityStatus.localeCompare(b.availabilityStatus)
default: return 0
}
})
}
return result
}
export default function Properties() {
const navigate = useNavigate()
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
const [selectedId, setSelectedId] = useState<string | null>(null)
const [filters, setFilters] = useState<PropertyTableFilters>({})
const [view, setView] = useState<'list' | 'grid'>(() =>
(localStorage.getItem('view-properties') as 'list' | 'grid') ?? 'list'
)
const { data: properties = [], isLoading, isError } = useProperties()
const filtered = useMemo(() => applyFilters(properties, filters), [properties, filters])
// Decision-relevant aggregates — single pass over the full list
const { matchReady, criticalGaps, lowConfidence, staleOrOutdated, allMissingFields } = useMemo(() => {
const matchReady: typeof properties = []
const criticalGaps: typeof properties = []
const lowConfidence: typeof properties = []
const staleOrOutdated: typeof properties = []
const missingSet = new Set<string>()
for (const p of properties) {
if ((p.availabilityStatus === 'AVAILABLE_NOW' || p.availabilityStatus === 'AVAILABLE_SOON') &&
p.confidenceScore >= 0.7 && p.dataQuality.missingCriticalFields.length === 0)
matchReady.push(p)
if (p.dataQuality.missingCriticalFields.length > 0) {
criticalGaps.push(p)
p.dataQuality.missingCriticalFields.forEach(f => missingSet.add(f))
}
if (p.confidenceScore < 0.55) lowConfidence.push(p)
if (p.dataQuality.freshness === 'STALE' || p.dataQuality.freshness === 'OUTDATED') staleOrOutdated.push(p)
}
return {
matchReady,
criticalGaps,
lowConfidence,
staleOrOutdated,
allMissingFields: [...missingSet].slice(0, 4),
}
}, [properties])
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<PageHeader
title="Objektverwaltung"
subtitle={`${filtered.length} von ${properties.length} Objekten`}
secondaryActions={
<ViewToggle
view={view}
onChange={v => { setView(v); localStorage.setItem('view-properties', v) }}
/>
}
/>
{!isLoading && properties.length > 0 && (
<DecisionContextPanel
decision="Welche Objekte sind matchbereit — und wo blockieren Datenlücken Matches?"
context="Objekte mit fehlenden Pflichtfeldern oder Konfidenz < 55% erscheinen im Match-Center nicht oder mit niedrigem Rang."
metrics={[
{ label: 'matchbereit', value: matchReady.length, severity: matchReady.length > 0 ? 'positive' : 'warning' },
...(criticalGaps.length > 0
? [{ label: 'kritische Datenlücken', value: criticalGaps.length, severity: 'critical' as const }]
: []
),
...(lowConfidence.length > 0
? [{ label: 'Konfidenz < 55%', value: lowConfidence.length, severity: 'warning' as const }]
: []
),
...(staleOrOutdated.length > 0
? [{ label: 'veraltete Daten', value: staleOrOutdated.length, severity: 'warning' as const }]
: []
),
]}
missing={allMissingFields.length > 0
? [`Fehlende Pflichtfelder bei ${criticalGaps.length} Objekten: ${allMissingFields.join(', ')}`]
: []
}
risks={[
...(criticalGaps.length > 0
? [`${criticalGaps.length} Objekte werden potenziellen Mietern nicht angezeigt`]
: []
),
...(staleOrOutdated.length > 0
? [`${staleOrOutdated.length} Objekte mit veralteten Preisen oder Verfügbarkeiten`]
: []
),
]}
actions={[
{
label: 'Datenpflege starten',
primary: criticalGaps.length > 0 || staleOrOutdated.length > 0,
onClick: () => navigate('/supply/data-quality'),
},
]}
/>
)}
<PropertyFilterBar filters={filters} onFiltersChange={setFilters} />
<Box sx={{ flex: 1, overflowY: 'auto' }}>
{view === 'grid' ? (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 2, p: 2 }}>
{filtered.map(p => (
<PropertyIntelligenceCard key={p.id} property={p} onSelect={setSelectedId} />
))}
</Box>
) : (
<PropertyTable
properties={filtered}
isLoading={isLoading}
isError={isError}
selectedId={selectedId}
onSelect={setSelectedId}
filters={filters}
onFiltersChange={setFilters}
/>
)}
</Box>
<Drawer
anchor="right"
open={!!selectedId}
onClose={() => setSelectedId(null)}
slotProps={{ paper: { sx: { width: isMobile ? '100vw' : 650, boxShadow: '-4px 0 24px rgba(0,0,0,0.10)' } } }}
>
{selectedId && (
<PropertyDetailView propertyId={selectedId} onClose={() => setSelectedId(null)} />
)}
</Drawer>
</Box>
)
}