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:
+70
-353
@@ -1,375 +1,92 @@
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
Chip,
|
||||
IconButton,
|
||||
LinearProgress,
|
||||
MenuItem,
|
||||
Select,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Eye, MoreHorizontal, Plus } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { EmptyState, ErrorState, LoadingPage } from '../../components/ui'
|
||||
import { propertyService } from '../../services/propertyService'
|
||||
import { AssetType, AvailabilityStatus, ResultType } from '../../domain/enums'
|
||||
import { Box } from '@mui/material'
|
||||
import { PageHeader } from '../../components/layout'
|
||||
import { useProperties } from '../../hooks/useProperties'
|
||||
import { PropertyFilterBar, PropertyTable, PropertyDetailView } from '../../components/supply'
|
||||
import type { PropertyTableFilters } from '../../components/supply'
|
||||
import type { Property } from '../../domain/property'
|
||||
|
||||
function getAssetTypeLabel(type: AssetType): string {
|
||||
switch (type) {
|
||||
case AssetType.OFFICE: return 'Büro'
|
||||
case AssetType.LOGISTICS: return 'Logistik'
|
||||
case AssetType.RETAIL: return 'Retail'
|
||||
case AssetType.GASTRO: return 'Gastro'
|
||||
case AssetType.PRODUCTION: return 'Produktion'
|
||||
case AssetType.MIXED: return 'Gemischt'
|
||||
case AssetType.LIGHT_INDUSTRIAL: return 'Leichtindustrie'
|
||||
case AssetType.UNKNOWN: return 'Unbekannt'
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function getAssetTypeColor(type: AssetType): string {
|
||||
switch (type) {
|
||||
case AssetType.OFFICE: return '#1e3a5f'
|
||||
case AssetType.LOGISTICS: return '#d97706'
|
||||
case AssetType.RETAIL: return '#7c3aed'
|
||||
case AssetType.GASTRO: return '#0d9488'
|
||||
case AssetType.PRODUCTION: return '#92400e'
|
||||
case AssetType.MIXED: return '#6b7280'
|
||||
case AssetType.LIGHT_INDUSTRIAL: return '#b45309'
|
||||
case AssetType.UNKNOWN: return '#9ca3af'
|
||||
if (filters.assetTypes && filters.assetTypes.length > 0) {
|
||||
result = result.filter(p => filters.assetTypes!.includes(p.assetType))
|
||||
}
|
||||
}
|
||||
|
||||
function getAvailabilityLabel(status: AvailabilityStatus): string {
|
||||
switch (status) {
|
||||
case AvailabilityStatus.AVAILABLE_NOW: return 'Verfügbar'
|
||||
case AvailabilityStatus.AVAILABLE_SOON: return 'Bald verfügbar'
|
||||
case AvailabilityStatus.FUTURE_SIGNAL: return 'Zukunftssignal'
|
||||
case AvailabilityStatus.OCCUPIED: return 'Belegt'
|
||||
case AvailabilityStatus.UNKNOWN: return 'Unbekannt'
|
||||
if (filters.availabilityStatus) {
|
||||
result = result.filter(p => p.availabilityStatus === filters.availabilityStatus)
|
||||
}
|
||||
}
|
||||
|
||||
function getAvailabilityColor(status: AvailabilityStatus): 'success' | 'warning' | 'secondary' | 'error' | 'default' {
|
||||
switch (status) {
|
||||
case AvailabilityStatus.AVAILABLE_NOW: return 'success'
|
||||
case AvailabilityStatus.AVAILABLE_SOON: return 'warning'
|
||||
case AvailabilityStatus.FUTURE_SIGNAL: return 'secondary'
|
||||
case AvailabilityStatus.OCCUPIED: return 'error'
|
||||
case AvailabilityStatus.UNKNOWN: return 'default'
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function getResultTypeLabel(type: ResultType): string {
|
||||
switch (type) {
|
||||
case ResultType.VERIFIED_PORTFOLIO: return 'Verified Portfolio'
|
||||
case ResultType.EXTERNAL_MARKET: return 'Marktinserat'
|
||||
case ResultType.FUTURE_AVAILABILITY: return 'Zukunftssignal'
|
||||
}
|
||||
}
|
||||
|
||||
function getResultTypeColor(type: ResultType): string {
|
||||
switch (type) {
|
||||
case ResultType.VERIFIED_PORTFOLIO: return '#1e3a5f'
|
||||
case ResultType.EXTERNAL_MARKET: return '#d97706'
|
||||
case ResultType.FUTURE_AVAILABILITY: return '#7c3aed'
|
||||
}
|
||||
}
|
||||
|
||||
function getConfidenceColor(score: number): string {
|
||||
if (score >= 0.85) return '#1a7a4a'
|
||||
if (score >= 0.65) return '#1e3a5f'
|
||||
return '#d97706'
|
||||
}
|
||||
|
||||
function getQualityColor(score: number): 'success' | 'warning' | 'error' {
|
||||
if (score >= 0.8) return 'success'
|
||||
if (score >= 0.6) return 'warning'
|
||||
return 'error'
|
||||
return result
|
||||
}
|
||||
|
||||
export default function Properties() {
|
||||
const [selectedResultType, setSelectedResultType] = useState<ResultType | 'ALL'>('ALL')
|
||||
const [selectedAssetType, setSelectedAssetType] = useState<AssetType | 'ALL'>('ALL')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [filters, setFilters] = useState<PropertyTableFilters>({})
|
||||
|
||||
const { data: resp, isLoading, error } = useQuery({
|
||||
queryKey: ['properties'],
|
||||
queryFn: () => propertyService.getAll(),
|
||||
})
|
||||
|
||||
if (isLoading) return <LoadingPage />
|
||||
if (error) return <ErrorState />
|
||||
|
||||
const properties = resp?.data ?? []
|
||||
|
||||
const filtered = properties.filter(p => {
|
||||
if (selectedResultType !== 'ALL' && p.resultType !== selectedResultType) return false
|
||||
if (selectedAssetType !== 'ALL' && p.assetType !== selectedAssetType) return false
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase()
|
||||
const matchesTitle = p.title.toLowerCase().includes(q)
|
||||
const matchesCity = p.location.city.toLowerCase().includes(q)
|
||||
const matchesStreet = p.address.street.toLowerCase().includes(q)
|
||||
if (!matchesTitle && !matchesCity && !matchesStreet) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const sourceTypeFilters: { value: ResultType | 'ALL'; label: string; color: string }[] = [
|
||||
{ value: 'ALL', label: 'Alle', color: '#6b7280' },
|
||||
{ value: ResultType.VERIFIED_PORTFOLIO, label: 'Verified Portfolio', color: '#1e3a5f' },
|
||||
{ value: ResultType.EXTERNAL_MARKET, label: 'Marktinserate', color: '#d97706' },
|
||||
{ value: ResultType.FUTURE_AVAILABILITY, label: 'Zukunftssignale', color: '#7c3aed' },
|
||||
]
|
||||
const { data: properties = [], isLoading, isError } = useProperties()
|
||||
const filtered = applyFilters(properties, filters)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Page Header */}
|
||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid', borderColor: 'divider', px: 3, py: 2 }} className="flex items-center justify-between">
|
||||
<Box className="flex items-center gap-2">
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }} color="text.primary">Objekte</Typography>
|
||||
<Chip label={properties.length} size="small" sx={{ bgcolor: '#1e3a5f', color: 'white', fontWeight: 700 }} />
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<PageHeader
|
||||
title="Objektverwaltung"
|
||||
subtitle={`${filtered.length} von ${properties.length} Objekten`}
|
||||
/>
|
||||
<PropertyFilterBar filters={filters} onFiltersChange={setFilters} />
|
||||
|
||||
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
{/* Table */}
|
||||
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||||
<PropertyTable
|
||||
properties={filtered}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
/>
|
||||
</Box>
|
||||
<Tooltip title="In Entwicklung">
|
||||
<span>
|
||||
<button
|
||||
disabled
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '6px 16px',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
background: '#1e3a5f',
|
||||
color: 'white',
|
||||
cursor: 'not-allowed',
|
||||
opacity: 0.5,
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Neues Objekt
|
||||
</button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Content */}
|
||||
<Box sx={{ px: 3, py: 3 }} className="flex flex-col gap-3">
|
||||
|
||||
{/* Filter Bar */}
|
||||
<Card sx={{ elevation: 1, p: 1.5 }}>
|
||||
<Box className="flex flex-col gap-2">
|
||||
{/* Row 1: Source type chips */}
|
||||
<Box className="flex items-center gap-2 flex-wrap">
|
||||
{sourceTypeFilters.map(f => (
|
||||
<Chip
|
||||
key={f.value}
|
||||
label={f.label}
|
||||
variant={selectedResultType === f.value ? 'filled' : 'outlined'}
|
||||
size="small"
|
||||
onClick={() => setSelectedResultType(f.value)}
|
||||
sx={
|
||||
selectedResultType === f.value
|
||||
? { bgcolor: f.color, color: 'white', borderColor: f.color, fontWeight: 600, cursor: 'pointer' }
|
||||
: { borderColor: f.color, color: f.color, cursor: 'pointer' }
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
{/* Row 2: Asset type select + search */}
|
||||
<Box className="flex items-center gap-2">
|
||||
<Select
|
||||
value={selectedAssetType}
|
||||
onChange={e => setSelectedAssetType(e.target.value as AssetType | 'ALL')}
|
||||
size="small"
|
||||
sx={{ minWidth: 160 }}
|
||||
>
|
||||
<MenuItem value="ALL">Alle Typen</MenuItem>
|
||||
{Object.values(AssetType).map(t => (
|
||||
<MenuItem key={t} value={t}>{getAssetTypeLabel(t)}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<TextField
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder="Suche nach Titel, Stadt, Strasse…"
|
||||
size="small"
|
||||
sx={{ ml: 'auto', minWidth: 260 }}
|
||||
/>
|
||||
</Box>
|
||||
{/* Detail panel */}
|
||||
{selectedId && (
|
||||
<Box
|
||||
sx={{
|
||||
width: 480,
|
||||
flexShrink: 0,
|
||||
borderLeft: '1px solid #e2e8f0',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<PropertyDetailView propertyId={selectedId} onClose={() => setSelectedId(null)} />
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Properties Table */}
|
||||
{filtered.length === 0 ? (
|
||||
<EmptyState title="Keine Objekte gefunden" description="Passen Sie die Filter an, um Ergebnisse anzuzeigen." />
|
||||
) : (
|
||||
<Card sx={{ elevation: 1 }}>
|
||||
<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><Typography variant="caption" sx={{ fontWeight: 600 }}>Fläche</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Miete/m²</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Quelle</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Konfidenz</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Datenqualität</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Verfügbarkeit</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Aktionen</Typography></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filtered.map(property => {
|
||||
const hasCritical = property.dataQuality.missingCriticalFields.length > 0
|
||||
return (
|
||||
<TableRow
|
||||
key={property.id}
|
||||
hover
|
||||
sx={hasCritical ? { bgcolor: 'rgba(192,57,43,0.04)' } : {}}
|
||||
>
|
||||
{/* Objekt */}
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>{property.title}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{property.address.street} {property.address.houseNumber}, {property.address.city}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
|
||||
{/* Typ */}
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={getAssetTypeLabel(property.assetType)}
|
||||
size="small"
|
||||
sx={{ bgcolor: getAssetTypeColor(property.assetType), color: 'white', fontSize: 11 }}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* Standort */}
|
||||
<TableCell>
|
||||
<Typography variant="body2">{property.location.city}</Typography>
|
||||
{property.location.canton && (
|
||||
<Typography variant="caption" color="text.secondary">{property.location.canton}</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* Fläche */}
|
||||
<TableCell>
|
||||
<Typography variant="body2">{property.areaSqm.toLocaleString('de-CH')} m²</Typography>
|
||||
</TableCell>
|
||||
|
||||
{/* Miete/m² */}
|
||||
<TableCell>
|
||||
<Typography variant="body2">CHF {property.rentPricePerSqm}</Typography>
|
||||
</TableCell>
|
||||
|
||||
{/* Quelle */}
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={getResultTypeLabel(property.resultType)}
|
||||
size="small"
|
||||
sx={{ bgcolor: getResultTypeColor(property.resultType), color: 'white', fontSize: 11 }}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* Konfidenz */}
|
||||
<TableCell>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 600, color: getConfidenceColor(property.confidenceScore) }}
|
||||
>
|
||||
{Math.round(property.confidenceScore * 100)}%
|
||||
</Typography>
|
||||
</TableCell>
|
||||
|
||||
{/* Datenqualität */}
|
||||
<TableCell>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
{property.dataQuality.missingCriticalFields.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600 }}>Kritische Felder fehlen:</Typography>
|
||||
{property.dataQuality.missingCriticalFields.map(f => (
|
||||
<Typography key={f} variant="caption" sx={{ display: 'block' }}>• {f}</Typography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{property.dataQuality.warnings.length > 0 && (
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600 }}>Warnungen:</Typography>
|
||||
{property.dataQuality.warnings.map((w, i) => (
|
||||
<Typography key={i} variant="caption" sx={{ display: 'block' }}>• {w}</Typography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{property.dataQuality.missingCriticalFields.length === 0 && property.dataQuality.warnings.length === 0 && (
|
||||
<Typography variant="caption">Keine Probleme</Typography>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box sx={{ width: 80 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={property.dataQuality.score * 100}
|
||||
color={getQualityColor(property.dataQuality.score)}
|
||||
sx={{ height: 6, borderRadius: 3 }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{Math.round(property.dataQuality.score * 100)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
|
||||
{/* Verfügbarkeit */}
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={getAvailabilityLabel(property.availabilityStatus)}
|
||||
sx={{ color: getAvailabilityColor(property.availabilityStatus) }}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* Aktionen */}
|
||||
<TableCell>
|
||||
<Box className="flex items-center gap-1">
|
||||
<Tooltip title="Details (in Entwicklung)">
|
||||
<span>
|
||||
<IconButton size="small" disabled>
|
||||
<Eye size={16} />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Mehr Aktionen (in Entwicklung)">
|
||||
<span>
|
||||
<IconButton size="small" disabled>
|
||||
<MoreHorizontal size={16} />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user