feat: F007 property repository & detail view

- useProperties: added usePropertyById, usePropertyMatches, usePropertySignals hooks
- propertyService.getProperties(), matchService.getMatchesForProperty(),
  futureSignalService.getSignalsForProperty() added
- PropertyFilterBar: asset type chips, availability select, sort select, search
- PropertyTable: sortable columns, row selection highlight, loading skeletons,
  empty/error states, Eye/Plus/Bookmark actions
- PropertyCard: decision-object card with header, primary, matchability,
  data quality and action zones; card states (selected, compareSelected,
  stale, low-confidence)
- PropertyDetailView: 7-tab detail panel (Übersicht, Hard Facts, Soft Factors,
  Matchability, Datenqualität, Quelle, Signale) with low-quality banner,
  missing field UX, data update CTA
- PropertyDetailSkeleton: loading state
- Properties page: two-panel layout (table + sliding detail panel)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-15 16:53:51 +02:00
parent 7b7be0b436
commit 3cd2e83b25
12 changed files with 1303 additions and 354 deletions
+271
View File
@@ -0,0 +1,271 @@
import {
Alert,
Box,
Chip,
IconButton,
LinearProgress,
Skeleton,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Tooltip,
Typography,
} from '@mui/material'
import { Bookmark, Eye, Plus } from 'lucide-react'
import type { Property } from '../../domain/property'
import type { PropertyTableFilters } from './PropertyFilterBar'
import {
getAssetTypeColor,
getAssetTypeLabel,
getAvailabilityChipColor,
getAvailabilityLabel,
getResultTypeColor,
getResultTypeLabel,
qualityColor,
} from './propertyHelpers'
interface PropertyTableProps {
properties: Property[]
isLoading: boolean
isError: boolean
selectedId: string | null
onSelect: (id: string) => void
onViewDetail?: (id: string) => void
filters: PropertyTableFilters
onFiltersChange: (f: PropertyTableFilters) => void
}
const COL_HEADERS = [
'Objekt', 'Typ', 'Standort', 'Fläche', 'Miete/m²',
'Verfügbarkeit', 'Konfidenz', 'Datenqualität', 'Quelle', 'Aktionen',
]
function LoadingRows() {
return (
<>
{Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
{COL_HEADERS.map((h) => (
<TableCell key={h}>
<Skeleton variant="text" width={h === 'Objekt' ? 140 : 60} />
</TableCell>
))}
</TableRow>
))}
</>
)
}
export function PropertyTable({
properties,
isLoading,
isError,
selectedId,
onSelect,
onViewDetail,
filters,
onFiltersChange,
}: PropertyTableProps) {
if (isError) {
return <Alert severity="error" sx={{ m: 3 }}>Objekte konnten nicht geladen werden.</Alert>
}
function handleSort(field: PropertyTableFilters['sortBy']) {
if (filters.sortBy === field) {
onFiltersChange({ ...filters, sortDir: filters.sortDir === 'asc' ? 'desc' : 'asc' })
} else {
onFiltersChange({ ...filters, sortBy: field, sortDir: 'desc' })
}
}
function SortableHeader({ field, label }: { field: PropertyTableFilters['sortBy']; label: string }) {
const isActive = filters.sortBy === field
return (
<Typography
variant="caption"
sx={{
fontWeight: 600,
cursor: 'pointer',
color: isActive ? 'primary.main' : 'text.primary',
userSelect: 'none',
'&:hover': { color: 'primary.main' },
}}
onClick={() => handleSort(field)}
>
{label}{isActive ? (filters.sortDir === 'asc' ? ' ↑' : ' ↓') : ''}
</Typography>
)
}
return (
<Box sx={{ overflowX: 'auto' }}>
<Table stickyHeader size="small">
<TableHead>
<TableRow sx={{ bgcolor: 'grey.50' }}>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Objekt</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Typ</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Standort</Typography></TableCell>
<TableCell><SortableHeader field="area" label="Fläche (m²)" /></TableCell>
<TableCell><SortableHeader field="rent" label="Miete/m²" /></TableCell>
<TableCell><SortableHeader field="availability" label="Verfügbarkeit" /></TableCell>
<TableCell><SortableHeader field="confidence" label="Konfidenz" /></TableCell>
<TableCell><SortableHeader field="dataQuality" label="Datenqualität" /></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Quelle</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Aktionen</Typography></TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<LoadingRows />
) : properties.length === 0 ? (
<TableRow>
<TableCell colSpan={10}>
<Typography variant="body2" sx={{ color: 'text.secondary', textAlign: 'center', py: 4 }}>
Keine Objekte gefunden.
</Typography>
</TableCell>
</TableRow>
) : (
properties.map(p => {
const isSelected = p.id === selectedId
const qScore = p.dataQuality.score
const hasCritical = p.dataQuality.missingCriticalFields.length > 0
return (
<TableRow
key={p.id}
hover
onClick={() => onSelect(p.id)}
sx={{
cursor: 'pointer',
bgcolor: isSelected
? 'rgba(30,58,95,0.06)'
: hasCritical
? 'rgba(192,57,43,0.03)'
: 'inherit',
borderLeft: isSelected ? '3px solid #1e3a5f' : '3px solid transparent',
'&:hover': { bgcolor: isSelected ? 'rgba(30,58,95,0.08)' : 'rgba(0,0,0,0.02)' },
}}
>
{/* Objekt */}
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 500, lineHeight: 1.3 }}>
{p.title}
</Typography>
<Typography variant="caption" color="text.secondary">
{p.address.street} {p.address.houseNumber}, {p.address.city}
</Typography>
</TableCell>
{/* Typ */}
<TableCell>
<Chip
label={getAssetTypeLabel(p.assetType)}
size="small"
sx={{ bgcolor: getAssetTypeColor(p.assetType), color: 'white', fontSize: '0.68rem' }}
/>
</TableCell>
{/* Standort */}
<TableCell>
<Typography variant="body2">{p.location.city}</Typography>
{p.location.canton && (
<Typography variant="caption" color="text.secondary">{p.location.canton}</Typography>
)}
</TableCell>
{/* Fläche */}
<TableCell>
<Typography variant="body2">{p.areaSqm.toLocaleString('de-CH')}</Typography>
</TableCell>
{/* Miete */}
<TableCell>
<Typography variant="body2">
{p.rentPricePerSqm > 0 ? `CHF ${p.rentPricePerSqm}` : <span style={{ color: '#94a3b8' }}>k.A.</span>}
</Typography>
</TableCell>
{/* Verfügbarkeit */}
<TableCell>
<Chip
label={getAvailabilityLabel(p.availabilityStatus)}
size="small"
color={getAvailabilityChipColor(p.availabilityStatus)}
variant="outlined"
/>
</TableCell>
{/* Konfidenz */}
<TableCell>
<Typography
variant="body2"
sx={{
fontWeight: 600,
color: p.confidenceScore >= 0.85 ? '#1a7a4a' : p.confidenceScore >= 0.65 ? '#1e3a5f' : '#d97706',
}}
>
{Math.round(p.confidenceScore * 100)}%
</Typography>
</TableCell>
{/* Datenqualität */}
<TableCell>
<Tooltip
title={hasCritical ? `Kritische Felder: ${p.dataQuality.missingCriticalFields.join(', ')}` : 'Keine kritischen Lücken'}
placement="top"
>
<Box sx={{ width: 80 }}>
<LinearProgress
variant="determinate"
value={qScore * 100}
color={qualityColor(qScore)}
sx={{ height: 6, borderRadius: 3, mb: 0.25 }}
/>
<Typography variant="caption" color="text.secondary">
{Math.round(qScore * 100)}%
</Typography>
</Box>
</Tooltip>
</TableCell>
{/* Quelle */}
<TableCell>
<Chip
label={getResultTypeLabel(p.resultType)}
size="small"
sx={{ bgcolor: getResultTypeColor(p.resultType), color: 'white', fontSize: '0.68rem' }}
/>
</TableCell>
{/* Aktionen */}
<TableCell onClick={e => e.stopPropagation()}>
<Box sx={{ display: 'flex', gap: 0.25 }}>
<Tooltip title="Details anzeigen">
<IconButton size="small" onClick={() => onViewDetail ? onViewDetail(p.id) : onSelect(p.id)}>
<Eye size={15} />
</IconButton>
</Tooltip>
<Tooltip title="Zum Vergleich hinzufügen">
<IconButton size="small">
<Plus size={15} />
</IconButton>
</Tooltip>
<Tooltip title="Zur Shortlist">
<IconButton size="small">
<Bookmark size={15} />
</IconButton>
</Tooltip>
</Box>
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
</Box>
)
}