920ce8eb09
- Dashboard: remove QuickActionPanel, StrongMatchOverview, FutureSignalWidget,
DataQualityWidget, header buttons, Marktchancen nav; KpiGrid → 4 cards
- Property domain: 9 new fields (leaseTerm, leaseStartDate/End, breakoutOption,
currentTenant, importedFrom, importedAt, lastUpdatedAt)
- Mock data: annual CHF/m²/Jahr prices (×12), Swiss images, tenant/lease data
for all 10 VERIFIED_PORTFOLIO properties; need budgets updated to annual
- PropertyTable: swap columns — add Aktueller Mieter, Mietlaufzeit,
Breakoutoption, Breakoutoption Zeitpunkt; remove Verfügbarkeit, Konfidenz, Quelle
- PropertyDetailView: 3 tabs (Übersicht, Matchability, Marktsignale) with
inline edit mode, NeedMatchCard list, PropertyMarketSignalsTab
- New: marketReport domain + mock data + service, NeedMatchCard,
PropertyMarketSignalsTab with 4 sections + PDF button
- matchService: getNeedMatchesForProperty(propertyId, {minScore})
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
273 lines
10 KiB
TypeScript
273 lines
10 KiB
TypeScript
import {
|
||
Alert,
|
||
Box,
|
||
Chip,
|
||
IconButton,
|
||
LinearProgress,
|
||
Skeleton,
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableRow,
|
||
Tooltip,
|
||
Typography,
|
||
} from '@mui/material'
|
||
import { Bookmark, Eye } from 'lucide-react'
|
||
import type { Property } from '../../domain/property'
|
||
import type { PropertyTableFilters } from './PropertyFilterBar'
|
||
import {
|
||
getAssetTypeColor,
|
||
getAssetTypeLabel,
|
||
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 CHF/m²/Jahr',
|
||
'Aktueller Mieter', 'Mietlaufzeit', 'Breakoutoption', 'Breakoutoption Zeitpunkt',
|
||
'Datenqualität', '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 CHF/m²/Jahr" /></TableCell>
|
||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Aktueller Mieter</Typography></TableCell>
|
||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Mietlaufzeit</Typography></TableCell>
|
||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Breakoutoption</Typography></TableCell>
|
||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Breakoutoption Zeitpunkt</Typography></TableCell>
|
||
<TableCell><SortableHeader field="dataQuality" label="Datenqualität" /></TableCell>
|
||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Aktionen</Typography></TableCell>
|
||
</TableRow>
|
||
</TableHead>
|
||
<TableBody>
|
||
{isLoading ? (
|
||
<LoadingRows />
|
||
) : properties.length === 0 ? (
|
||
<TableRow>
|
||
<TableCell colSpan={11}>
|
||
<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.toLocaleString('de-CH')}` : <span style={{ color: '#94a3b8' }}>k.A.</span>}
|
||
</Typography>
|
||
</TableCell>
|
||
|
||
{/* Aktueller Mieter */}
|
||
<TableCell>
|
||
<Typography variant="body2" sx={{ whiteSpace: 'nowrap' }}>
|
||
{p.currentTenant ?? <span style={{ color: '#94a3b8' }}>–</span>}
|
||
</Typography>
|
||
</TableCell>
|
||
|
||
{/* Mietlaufzeit */}
|
||
<TableCell>
|
||
<Typography variant="body2">
|
||
{p.leaseTerm ?? <span style={{ color: '#94a3b8' }}>–</span>}
|
||
</Typography>
|
||
</TableCell>
|
||
|
||
{/* Breakoutoption */}
|
||
<TableCell>
|
||
{p.breakoutOption == null
|
||
? <Typography variant="body2" sx={{ color: '#94a3b8' }}>–</Typography>
|
||
: <Chip
|
||
label={p.breakoutOption ? 'Ja' : 'Nein'}
|
||
size="small"
|
||
sx={{
|
||
bgcolor: p.breakoutOption ? '#dcfce7' : '#f1f5f9',
|
||
color: p.breakoutOption ? '#166534' : '#64748b',
|
||
fontSize: '0.68rem',
|
||
}}
|
||
/>
|
||
}
|
||
</TableCell>
|
||
|
||
{/* Breakoutoption Zeitpunkt */}
|
||
<TableCell>
|
||
<Typography variant="body2">
|
||
{p.breakoutOptionDate
|
||
? new Date(p.breakoutOptionDate).toLocaleDateString('de-CH')
|
||
: <span style={{ color: '#94a3b8' }}>–</span>
|
||
}
|
||
</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>
|
||
|
||
{/* 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="Zur Shortlist">
|
||
<IconButton size="small">
|
||
<Bookmark size={15} />
|
||
</IconButton>
|
||
</Tooltip>
|
||
</Box>
|
||
</TableCell>
|
||
</TableRow>
|
||
)
|
||
})
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</Box>
|
||
)
|
||
}
|