36ce548169
`npx tsc -b` brach mit 16 Fehlern ab. Neue Fehler wären darin untergegangen.
Echte Defekte:
- LatentInquiriesTab: OwnPropertyMatchList wurde verwendet, aber nie importiert
— der Zweig "Eigene Objekte" auf Mobil hätte zur Laufzeit geworfen
- unitService: throwServiceError nimmt ein Argument, nicht zwei
- SavedNeedDetail: MUI v9 kennt kein `inputProps` mehr, ersetzt durch slotProps
- Review{Status,Priority}Badge: `showBorder` existiert an GenericBadge nicht;
die Default-Variante "outlined" zeichnet den Rand ohnehin, Darstellung unverändert
- UnifiedResultFeed: die Ternärkette verglich theme.text gegen zwei Werte, die
SCORE_THEME seit einer Palettenumstellung nicht mehr führt — sie lief immer in
denselben Zweig. Auf diesen einen Wert zusammengezogen, Darstellung unverändert,
nebenbei zwei rohe Hex-Werte weniger
Der Rest waren ungenutzte Importe und Bindings (noUnusedLocals).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
259 lines
9.8 KiB
TypeScript
259 lines
9.8 KiB
TypeScript
import { useState, useMemo } from 'react'
|
|
import { Box, Button, Chip, Collapse, Drawer, Typography, useMediaQuery, useTheme } from '@mui/material'
|
|
import { useNavigate } from 'react-router'
|
|
import { ChevronDown, ChevronUp } from 'lucide-react'
|
|
import { PageHeader } from '../../components/layout'
|
|
import { ViewToggle } from '../../components/shared'
|
|
import { useProperties } from '../../hooks/useProperties'
|
|
import { useActiveInquiries } from '../../hooks/useInquiries'
|
|
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 [columns, setColumns] = useState<3 | 5 | 10>(() =>
|
|
(Number(localStorage.getItem('view-properties-cols')) as 3 | 5 | 10) || 3
|
|
)
|
|
const [headerOpen, setHeaderOpen] = useState(() =>
|
|
localStorage.getItem('props-header-open') !== 'false'
|
|
)
|
|
|
|
const { data: properties = [], isLoading, isError } = useProperties()
|
|
const { data: inquiries = [] } = useActiveInquiries()
|
|
|
|
const inquiryCountByProperty = useMemo(() => {
|
|
const map = new Map<string, number>()
|
|
for (const inq of inquiries) {
|
|
if (inq.propertyId) map.set(inq.propertyId, (map.get(inq.propertyId) ?? 0) + 1)
|
|
}
|
|
return map
|
|
}, [inquiries])
|
|
|
|
const filtered = useMemo(() => applyFilters(properties, filters), [properties, filters])
|
|
|
|
// Decision-relevant aggregates — single pass over the full list
|
|
const { matchReady, criticalGaps, staleOrOutdated } = 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={
|
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
|
{view === 'grid' && (
|
|
<Box sx={{ display: 'flex', border: '1px solid #e2e8f0', borderRadius: 1, overflow: 'hidden' }}>
|
|
{([3, 5, 10] as const).map(n => (
|
|
<Box
|
|
key={n}
|
|
onClick={() => { setColumns(n); localStorage.setItem('view-properties-cols', String(n)) }}
|
|
sx={{
|
|
width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
cursor: 'pointer', fontSize: '0.72rem', fontWeight: 600,
|
|
bgcolor: columns === n ? '#152642' : 'transparent',
|
|
color: columns === n ? 'white' : '#64748b',
|
|
'&:hover': { bgcolor: columns === n ? '#162d4a' : '#f1f5f9' },
|
|
}}
|
|
>
|
|
{n}
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
)}
|
|
<ViewToggle
|
|
view={view}
|
|
onChange={v => { setView(v); localStorage.setItem('view-properties', v) }}
|
|
/>
|
|
</Box>
|
|
}
|
|
/>
|
|
|
|
{/* Collapse toggle strip */}
|
|
<Box
|
|
onClick={() => {
|
|
const next = !headerOpen
|
|
setHeaderOpen(next)
|
|
localStorage.setItem('props-header-open', String(next))
|
|
}}
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
px: 3,
|
|
py: 0.5,
|
|
bgcolor: '#f8fafc',
|
|
borderBottom: '1px solid #e2e8f0',
|
|
cursor: 'pointer',
|
|
flexShrink: 0,
|
|
userSelect: 'none',
|
|
'&:hover': { bgcolor: '#f1f5f9' },
|
|
}}
|
|
>
|
|
<Typography sx={{ fontSize: '0.68rem', color: '#94a3b8', letterSpacing: 0.5, textTransform: 'uppercase' }}>
|
|
Filter & Übersicht
|
|
</Typography>
|
|
{headerOpen ? <ChevronUp size={12} color="#94a3b8" /> : <ChevronDown size={12} color="#94a3b8" />}
|
|
</Box>
|
|
|
|
<Collapse in={headerOpen}>
|
|
{!isLoading && properties.length > 0 && (
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
px: 2.5,
|
|
py: 0.75,
|
|
bgcolor: '#f8fafc',
|
|
borderBottom: '1px solid #e2e8f0',
|
|
borderLeft: '3px solid #1e3a5f',
|
|
flexShrink: 0,
|
|
gap: 1,
|
|
}}
|
|
>
|
|
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', alignItems: 'center' }}>
|
|
<Chip
|
|
label={`${matchReady.length} matchbereit`}
|
|
size="small"
|
|
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 600,
|
|
bgcolor: matchReady.length > 0 ? '#f0fdf4' : '#fef9c3',
|
|
color: matchReady.length > 0 ? '#1a7a4a' : '#92400e' }}
|
|
/>
|
|
{criticalGaps.length > 0 && (
|
|
<Chip
|
|
label={`${criticalGaps.length} kritische Datenlücken`}
|
|
size="small"
|
|
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#fef2f2', color: '#991b1b' }}
|
|
/>
|
|
)}
|
|
{staleOrOutdated.length > 0 && (
|
|
<Chip
|
|
label={`${staleOrOutdated.length} veraltete Daten`}
|
|
size="small"
|
|
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#fef9c3', color: '#92400e' }}
|
|
/>
|
|
)}
|
|
</Box>
|
|
<Button
|
|
size="small"
|
|
variant={criticalGaps.length > 0 || staleOrOutdated.length > 0 ? 'contained' : 'outlined'}
|
|
onClick={() => navigate('/supply/data-quality')}
|
|
sx={criticalGaps.length > 0 || staleOrOutdated.length > 0
|
|
? { bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none', fontSize: '0.75rem', py: 0.375, px: 1.5, flexShrink: 0 }
|
|
: { textTransform: 'none', fontSize: '0.75rem', py: 0.375, px: 1.5, flexShrink: 0 }
|
|
}
|
|
>
|
|
Datenpflege
|
|
</Button>
|
|
</Box>
|
|
)}
|
|
|
|
<PropertyFilterBar filters={filters} onFiltersChange={setFilters} />
|
|
</Collapse>
|
|
|
|
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
|
{view === 'grid' ? (
|
|
<Box sx={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: 3, p: 3 }}>
|
|
{filtered.map(p => (
|
|
<PropertyIntelligenceCard key={p.id} property={p} onSelect={setSelectedId} inquiryCount={inquiryCountByProperty.get(p.id) ?? 0} />
|
|
))}
|
|
</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' : { md: 520, lg: 580, xl: 640 }, boxShadow: '-4px 0 24px rgba(0,0,0,0.10)' } } }}
|
|
>
|
|
{selectedId && (
|
|
<PropertyDetailView propertyId={selectedId} onClose={() => setSelectedId(null)} />
|
|
)}
|
|
</Drawer>
|
|
</Box>
|
|
)
|
|
}
|