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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user