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,160 @@
|
|||||||
|
import { Box, Button, Card, CardContent, Chip, LinearProgress, Typography } from '@mui/material'
|
||||||
|
import type { Property } from '../../domain/property'
|
||||||
|
import { FreshnessStatus } from '../../domain/enums'
|
||||||
|
import {
|
||||||
|
getAssetTypeColor,
|
||||||
|
getAssetTypeLabel,
|
||||||
|
getAvailabilityChipColor,
|
||||||
|
getAvailabilityLabel,
|
||||||
|
getResultTypeColor,
|
||||||
|
getResultTypeLabel,
|
||||||
|
qualityColor,
|
||||||
|
} from './propertyHelpers'
|
||||||
|
|
||||||
|
export interface PropertyCardProps {
|
||||||
|
property: Property
|
||||||
|
matchScore?: number
|
||||||
|
positiveFactors?: string[]
|
||||||
|
topTradeoff?: string
|
||||||
|
selected?: boolean
|
||||||
|
compareSelected?: boolean
|
||||||
|
onSelect?: () => void
|
||||||
|
onViewDetail?: () => void
|
||||||
|
onAddToCompare?: () => void
|
||||||
|
onSaveToShortlist?: () => void
|
||||||
|
onFindMatches?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const STALE_STATUSES: FreshnessStatus[] = [FreshnessStatus.STALE, FreshnessStatus.OUTDATED]
|
||||||
|
|
||||||
|
export function PropertyCard({
|
||||||
|
property: p,
|
||||||
|
matchScore,
|
||||||
|
positiveFactors,
|
||||||
|
topTradeoff,
|
||||||
|
selected,
|
||||||
|
compareSelected,
|
||||||
|
onSelect,
|
||||||
|
onViewDetail,
|
||||||
|
onAddToCompare,
|
||||||
|
onSaveToShortlist,
|
||||||
|
onFindMatches,
|
||||||
|
}: PropertyCardProps) {
|
||||||
|
const isStale = STALE_STATUSES.includes(p.dataQuality.freshness)
|
||||||
|
const isLowConfidence = p.confidenceScore < 0.65
|
||||||
|
|
||||||
|
const borderLeft = compareSelected
|
||||||
|
? '4px solid #1a7a4a'
|
||||||
|
: selected
|
||||||
|
? '4px solid #1e3a5f'
|
||||||
|
: '4px solid transparent'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
onClick={onSelect}
|
||||||
|
sx={{
|
||||||
|
borderLeft,
|
||||||
|
borderTop: isStale ? '2px solid #d97706' : undefined,
|
||||||
|
bgcolor: isLowConfidence ? 'rgba(251,191,36,0.04)' : 'inherit',
|
||||||
|
cursor: onSelect ? 'pointer' : 'default',
|
||||||
|
mb: 1.5,
|
||||||
|
transition: 'box-shadow 0.15s ease',
|
||||||
|
'&:hover': onSelect ? { boxShadow: 3 } : {},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardContent sx={{ p: 2, '&:last-child': { pb: 1.5 } }}>
|
||||||
|
{/* Header Zone */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', mb: 1 }}>
|
||||||
|
<Chip label={getAssetTypeLabel(p.assetType)} size="small" sx={{ bgcolor: getAssetTypeColor(p.assetType), color: 'white', fontSize: '0.68rem' }} />
|
||||||
|
<Chip label={getResultTypeLabel(p.resultType)} size="small" sx={{ bgcolor: getResultTypeColor(p.resultType), color: 'white', fontSize: '0.68rem' }} />
|
||||||
|
<Chip label={getAvailabilityLabel(p.availabilityStatus)} size="small" color={getAvailabilityChipColor(p.availabilityStatus)} variant="outlined" />
|
||||||
|
{isStale && <Chip label="Veraltete Daten" size="small" color="warning" variant="outlined" />}
|
||||||
|
{isLowConfidence && <Chip label="Niedrige Konfidenz" size="small" color="warning" variant="outlined" />}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Primary Zone */}
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, lineHeight: 1.3, mb: 0.25 }}>
|
||||||
|
{p.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 0.75 }}>
|
||||||
|
{p.address.street} {p.address.houseNumber}, {p.address.city}
|
||||||
|
{p.location.canton ? ` · ${p.location.canton}` : ''}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, mb: 1 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">Fläche</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{p.areaSqm.toLocaleString('de-CH')} m²</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">Miete/m²/Jahr</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||||
|
{p.rentPricePerSqm > 0 ? `CHF ${p.rentPricePerSqm}` : <Typography component="span" variant="body2" color="text.disabled">k.A.</Typography>}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">Verfügbar ab</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||||
|
{p.availabilityDate ? new Date(p.availabilityDate).toLocaleDateString('de-CH') : '–'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Matchability Zone */}
|
||||||
|
{matchScore !== undefined ? (
|
||||||
|
<Box sx={{ p: 1, bgcolor: 'rgba(30,58,95,0.05)', borderRadius: 1, mb: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||||
|
<Chip
|
||||||
|
label={`Match ${matchScore}`}
|
||||||
|
size="small"
|
||||||
|
sx={{ fontWeight: 700, bgcolor: matchScore >= 80 ? '#1a7a4a' : matchScore >= 60 ? '#d97706' : '#c0392b', color: 'white' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{positiveFactors && positiveFactors.length > 0 && (
|
||||||
|
<Box sx={{ mb: 0.5 }}>
|
||||||
|
{positiveFactors.slice(0, 3).map((f, i) => (
|
||||||
|
<Typography key={i} variant="caption" sx={{ display: 'block', color: '#1a7a4a' }}>✓ {f}</Typography>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{topTradeoff && (
|
||||||
|
<Typography variant="caption" sx={{ color: '#d97706' }}>⚠ {topTradeoff}</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ p: 1, bgcolor: 'rgba(0,0,0,0.03)', borderRadius: 1, mb: 1 }}>
|
||||||
|
<Typography variant="caption" color="text.disabled">
|
||||||
|
Bedarfsprofil wählen für Matchbarkeit
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Data Quality Zone */}
|
||||||
|
<Box sx={{ mb: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">Datenqualität</Typography>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600 }}>{Math.round(p.dataQuality.score * 100)}%</Typography>
|
||||||
|
</Box>
|
||||||
|
<LinearProgress variant="determinate" value={p.dataQuality.score * 100} color={qualityColor(p.dataQuality.score)} sx={{ height: 4, borderRadius: 2, mb: 0.5 }} />
|
||||||
|
{p.dataQuality.missingCriticalFields.length > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||||
|
{p.dataQuality.missingCriticalFields.slice(0, 3).map(f => (
|
||||||
|
<Chip key={f} label={f} size="small" color="warning" variant="outlined" sx={{ fontSize: '0.65rem', height: 18 }} />
|
||||||
|
))}
|
||||||
|
{p.dataQuality.missingCriticalFields.length > 3 && (
|
||||||
|
<Typography variant="caption" color="warning.main">+{p.dataQuality.missingCriticalFields.length - 3} mehr</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Action Zone */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap' }} onClick={e => e.stopPropagation()}>
|
||||||
|
<Button size="small" variant="contained" sx={{ textTransform: 'none', fontSize: '0.75rem', py: 0.25 }} onClick={onViewDetail}>Details</Button>
|
||||||
|
<Button size="small" variant="outlined" sx={{ textTransform: 'none', fontSize: '0.75rem', py: 0.25 }} onClick={onAddToCompare}>Vergleichen</Button>
|
||||||
|
<Button size="small" variant="outlined" sx={{ textTransform: 'none', fontSize: '0.75rem', py: 0.25 }} onClick={onSaveToShortlist}>Shortlist</Button>
|
||||||
|
<Button size="small" variant="outlined" sx={{ textTransform: 'none', fontSize: '0.75rem', py: 0.25 }} onClick={onFindMatches}>Matches finden</Button>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Box, Skeleton, Tab, Tabs } from '@mui/material'
|
||||||
|
|
||||||
|
export function PropertyDetailSkeleton() {
|
||||||
|
return (
|
||||||
|
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Box sx={{ p: 2.5, borderBottom: '1px solid #e2e8f0' }}>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, mb: 1 }}>
|
||||||
|
<Skeleton variant="rounded" width={60} height={22} />
|
||||||
|
<Skeleton variant="rounded" width={70} height={22} />
|
||||||
|
<Skeleton variant="rounded" width={80} height={22} />
|
||||||
|
</Box>
|
||||||
|
<Skeleton variant="text" width="70%" height={28} />
|
||||||
|
<Skeleton variant="text" width="50%" height={20} />
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, mt: 1 }}>
|
||||||
|
<Skeleton variant="rounded" width={80} height={40} />
|
||||||
|
<Skeleton variant="rounded" width={80} height={40} />
|
||||||
|
<Skeleton variant="rounded" width={80} height={40} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Tabs value={0} sx={{ borderBottom: '1px solid #e2e8f0', px: 2 }}>
|
||||||
|
{['Übersicht', 'Hard Facts', 'Soft Factors'].map(label => (
|
||||||
|
<Tab key={label} label={label} disabled sx={{ textTransform: 'none', fontSize: '0.8rem' }} />
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
<Box sx={{ p: 2.5, flex: 1 }}>
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Box key={i} sx={{ mb: 2 }}>
|
||||||
|
<Skeleton variant="text" width="30%" height={16} sx={{ mb: 0.5 }} />
|
||||||
|
<Skeleton variant="text" width="60%" height={20} />
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
Divider,
|
||||||
|
IconButton,
|
||||||
|
LinearProgress,
|
||||||
|
Tab,
|
||||||
|
Tabs,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { X, ExternalLink } from 'lucide-react'
|
||||||
|
import type { Property } from '../../domain/property'
|
||||||
|
import type { Match } from '../../domain/match'
|
||||||
|
import type { FutureSignal } from '../../domain/futureSignal'
|
||||||
|
import { FreshnessStatus } from '../../domain/enums'
|
||||||
|
import { usePropertyById, usePropertyMatches, usePropertySignals } from '../../hooks/useProperties'
|
||||||
|
import { PropertyDetailSkeleton } from './PropertyDetailSkeleton'
|
||||||
|
import {
|
||||||
|
getAssetTypeColor,
|
||||||
|
getAssetTypeLabel,
|
||||||
|
getAvailabilityChipColor,
|
||||||
|
getAvailabilityLabel,
|
||||||
|
getResultTypeColor,
|
||||||
|
getResultTypeLabel,
|
||||||
|
qualityColor,
|
||||||
|
} from './propertyHelpers'
|
||||||
|
|
||||||
|
interface PropertyDetailViewProps {
|
||||||
|
propertyId: string
|
||||||
|
onClose?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Field({ label, value }: { label: string; value?: string | number | boolean | null }) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ mb: 1.5 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1 }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
{value !== undefined && value !== null && value !== '' ? (
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500, mt: 0.25 }}>
|
||||||
|
{typeof value === 'boolean' ? (value ? 'Ja' : 'Nein') : String(value)}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" color="text.disabled" sx={{ mt: 0.25 }}>—</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldGrid({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 0.5 }}>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionTitle({ title }: { title: string }) {
|
||||||
|
return (
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5, mt: 0.5 }}>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScoreRow({ label, value }: { label: string; value?: number }) {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ mb: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||||
|
<Typography variant="caption" color="text.disabled">—</Typography>
|
||||||
|
</Box>
|
||||||
|
<LinearProgress variant="determinate" value={0} sx={{ height: 4, borderRadius: 2, opacity: 0.3 }} />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const pct = value > 1 ? value : value * 100
|
||||||
|
return (
|
||||||
|
<Box sx={{ mb: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600 }}>{Math.round(pct)}</Typography>
|
||||||
|
</Box>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={Math.min(100, pct)}
|
||||||
|
color={pct >= 70 ? 'success' : pct >= 40 ? 'warning' : 'error'}
|
||||||
|
sx={{ height: 4, borderRadius: 2 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab panels ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function OverviewPanel({ p }: { p: Property }) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 1.5, mb: 2.5 }}>
|
||||||
|
{[
|
||||||
|
{ label: 'Fläche', value: `${p.areaSqm.toLocaleString('de-CH')} m²` },
|
||||||
|
{ label: 'Miete/m²/Jahr', value: p.rentPricePerSqm > 0 ? `CHF ${p.rentPricePerSqm}` : undefined },
|
||||||
|
{ label: 'Verfügbar ab', value: p.availabilityDate ? new Date(p.availabilityDate).toLocaleDateString('de-CH') : undefined },
|
||||||
|
].map(({ label, value }) => (
|
||||||
|
<Box key={label} sx={{ p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||||
|
<Typography variant="body1" sx={{ fontWeight: 700, mt: 0.25 }}>
|
||||||
|
{value ?? <span style={{ color: '#94a3b8' }}>k.A.</span>}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
{p.description && (
|
||||||
|
<>
|
||||||
|
<SectionTitle title="Beschreibung" />
|
||||||
|
<Typography variant="body2" sx={{ lineHeight: 1.6, color: 'text.secondary', mb: 2 }}>
|
||||||
|
{p.description}
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<SectionTitle title="Datenqualität" />
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">Score</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{Math.round(p.dataQuality.score * 100)}%</Typography>
|
||||||
|
</Box>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={p.dataQuality.score * 100}
|
||||||
|
color={qualityColor(p.dataQuality.score)}
|
||||||
|
sx={{ height: 8, borderRadius: 4, mb: 1 }}
|
||||||
|
/>
|
||||||
|
{p.dataQuality.warnings.length > 0 && (
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
{p.dataQuality.warnings.map((w, i) => (
|
||||||
|
<Typography key={i} variant="caption" sx={{ display: 'block', color: 'warning.main' }}>⚠ {w}</Typography>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HardFactsPanel({ p }: { p: Property }) {
|
||||||
|
const hf = p.hardFacts
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<SectionTitle title="Standort & Objekt" />
|
||||||
|
<FieldGrid>
|
||||||
|
<Field label="Titel" value={p.title} />
|
||||||
|
<Field label="Objekttyp" value={getAssetTypeLabel(p.assetType)} />
|
||||||
|
<Field label="Strasse" value={`${p.address.street} ${p.address.houseNumber}`} />
|
||||||
|
<Field label="PLZ / Stadt" value={`${p.address.postalCode} ${p.address.city}`} />
|
||||||
|
<Field label="Kanton" value={p.location.canton} />
|
||||||
|
<Field label="Region" value={p.location.region} />
|
||||||
|
<Field label="Land" value={p.address.country} />
|
||||||
|
<Field label="Nutzungsart" value={hf?.usageType} />
|
||||||
|
</FieldGrid>
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
<SectionTitle title="Fläche & Miete" />
|
||||||
|
<FieldGrid>
|
||||||
|
<Field label="Fläche (m²)" value={`${p.areaSqm.toLocaleString('de-CH')} m²`} />
|
||||||
|
<Field label="Fläche Min (m²)" value={p.areaSqmMin ? `${p.areaSqmMin.toLocaleString('de-CH')} m²` : undefined} />
|
||||||
|
<Field label="Fläche Max (m²)" value={p.areaSqmMax ? `${p.areaSqmMax.toLocaleString('de-CH')} m²` : undefined} />
|
||||||
|
<Field label="Miete/m²/Jahr" value={p.rentPricePerSqm > 0 ? `CHF ${p.rentPricePerSqm}` : undefined} />
|
||||||
|
<Field label="Jahresmiete (CHF/m²)" value={p.rentChfSqmYear ? `CHF ${p.rentChfSqmYear}` : undefined} />
|
||||||
|
<Field label="Monatsmiete gesamt" value={p.totalRentMonthly ? `CHF ${p.totalRentMonthly.toLocaleString('de-CH')}` : undefined} />
|
||||||
|
<Field label="Nebenkosten/m²" value={p.ancillaryCosts ? `CHF ${p.ancillaryCosts}` : undefined} />
|
||||||
|
<Field label="Vertragsdauer" value={p.contractDurationMonths ? `${p.contractDurationMonths} Monate` : undefined} />
|
||||||
|
</FieldGrid>
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
<SectionTitle title="Verfügbarkeit" />
|
||||||
|
<FieldGrid>
|
||||||
|
<Field label="Status" value={getAvailabilityLabel(p.availabilityStatus)} />
|
||||||
|
<Field label="Verfügbar ab" value={p.availabilityDate ? new Date(p.availabilityDate).toLocaleDateString('de-CH') : undefined} />
|
||||||
|
<Field label="Verfügbarkeitstyp" value={p.availabilityType} />
|
||||||
|
</FieldGrid>
|
||||||
|
{hf && (
|
||||||
|
<>
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
<SectionTitle title="Technische Details" />
|
||||||
|
<FieldGrid>
|
||||||
|
<Field label="Etage" value={hf.floor ?? p.floorLevel} />
|
||||||
|
<Field label="Ausbaustandard" value={hf.fitOut} />
|
||||||
|
<Field label="Parkplätze" value={hf.parking} />
|
||||||
|
<Field label="ÖV-Score" value={hf.publicTransportScore} />
|
||||||
|
<Field label="Deckenhöhe (m)" value={hf.ceilingHeightM} />
|
||||||
|
<Field label="Laderampen" value={hf.loadingDocksCount} />
|
||||||
|
<Field label="Stromanschluss (kVA)" value={hf.powerSupplyKva} />
|
||||||
|
<Field label="Serverraum" value={hf.hasServerRoom} />
|
||||||
|
<Field label="Barrierefrei" value={hf.isBarrierFree} />
|
||||||
|
</FieldGrid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SoftFactorsPanel({ p }: { p: Property }) {
|
||||||
|
const sf = p.softFactors
|
||||||
|
if (!sf) {
|
||||||
|
return <Typography variant="body2" color="text.secondary">Keine Soft Factors vorhanden.</Typography>
|
||||||
|
}
|
||||||
|
const prestige = sf.prestigeScore ?? sf.prestige
|
||||||
|
const visibility = sf.visibilityScore
|
||||||
|
const footfall = sf.footfallScore
|
||||||
|
const commuter = sf.commuterAccessScore ?? sf.accessibility
|
||||||
|
const talent = sf.talentAccessScore ?? sf.talentAccess
|
||||||
|
const esg = sf.esgScore
|
||||||
|
const flex = sf.flexibilityScore
|
||||||
|
const expansion = sf.expansionPotentialScore
|
||||||
|
const tax = sf.taxEnvironmentScore
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<SectionTitle title="Weiche Faktoren" />
|
||||||
|
<ScoreRow label="Prestige / Lage" value={prestige} />
|
||||||
|
<ScoreRow label="Sichtbarkeit" value={visibility} />
|
||||||
|
<ScoreRow label="Passantenfrequenz" value={footfall} />
|
||||||
|
<ScoreRow label="Pendlerzugang" value={commuter} />
|
||||||
|
<ScoreRow label="Talentpool-Zugang" value={talent} />
|
||||||
|
<ScoreRow label="ESG-Score" value={esg} />
|
||||||
|
<ScoreRow label="Flexibilität" value={flex} />
|
||||||
|
<ScoreRow label="Expansionspotenzial" value={expansion} />
|
||||||
|
<ScoreRow label="Steuerumfeld" value={tax} />
|
||||||
|
{sf.parkingSpots !== undefined && (
|
||||||
|
<Field label="Parkplätze" value={sf.parkingSpots} />
|
||||||
|
)}
|
||||||
|
{sf.publicTransportMinutes !== undefined && (
|
||||||
|
<Field label="ÖV-Erreichbarkeit (Min.)" value={sf.publicTransportMinutes} />
|
||||||
|
)}
|
||||||
|
{sf.infrastructureNotes && (
|
||||||
|
<Field label="Infrastruktur-Notizen" value={sf.infrastructureNotes} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MatchabilityPanel({ matches }: { matches: Match[] }) {
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Keine Matches für dieses Objekt vorhanden.
|
||||||
|
</Typography>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<SectionTitle title={`${matches.length} Matches gefunden`} />
|
||||||
|
{matches.map(m => (
|
||||||
|
<Box key={m.id} sx={{ p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 1, mb: 1.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>Bedarf: {m.needId}</Typography>
|
||||||
|
<Chip
|
||||||
|
label={`Score ${m.matchScore}`}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
bgcolor: m.matchScore >= 80 ? '#1a7a4a' : m.matchScore >= 60 ? '#d97706' : '#c0392b',
|
||||||
|
color: 'white',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{m.positiveFactors?.slice(0, 3).map((f, i) => (
|
||||||
|
<Typography key={i} variant="caption" sx={{ display: 'block', color: '#1a7a4a' }}>✓ {f.explanation ?? f.criterion}</Typography>
|
||||||
|
))}
|
||||||
|
{m.missingData && m.missingData.length > 0 && (
|
||||||
|
<Typography variant="caption" sx={{ color: 'warning.main', display: 'block', mt: 0.5 }}>
|
||||||
|
⚠ {m.missingData.length} fehlende Datenfelder
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataQualityPanel({ p }: { p: Property }) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<SectionTitle title="Datenqualität" />
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">Gesamtscore</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{Math.round(p.dataQuality.score * 100)}%</Typography>
|
||||||
|
</Box>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={p.dataQuality.score * 100}
|
||||||
|
color={qualityColor(p.dataQuality.score)}
|
||||||
|
sx={{ height: 8, borderRadius: 4, mb: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{p.dataQuality.missingCriticalFields.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionTitle title="Kritische fehlende Felder" />
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mb: 2 }}>
|
||||||
|
{p.dataQuality.missingCriticalFields.map(f => (
|
||||||
|
<Chip key={f} label={f} size="small" color="error" variant="outlined" />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{p.dataQuality.missingOptionalFields.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionTitle title="Optionale fehlende Felder" />
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mb: 2 }}>
|
||||||
|
{p.dataQuality.missingOptionalFields.map(f => (
|
||||||
|
<Chip key={f} label={f} size="small" color="warning" variant="outlined" />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{p.dataQuality.warnings.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionTitle title="Warnungen" />
|
||||||
|
{p.dataQuality.warnings.map((w, i) => (
|
||||||
|
<Alert key={i} severity="warning" sx={{ mb: 1, py: 0 }}>{w}</Alert>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
<Button variant="outlined" size="small" sx={{ textTransform: 'none' }}>
|
||||||
|
Datenaktualisierung anfragen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourcePanel({ p }: { p: Property }) {
|
||||||
|
const freshnessColor = p.dataQuality.freshness === FreshnessStatus.FRESH
|
||||||
|
? 'success'
|
||||||
|
: p.dataQuality.freshness === FreshnessStatus.STALE
|
||||||
|
? 'warning'
|
||||||
|
: 'error'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<SectionTitle title="Quellinformationen" />
|
||||||
|
<FieldGrid>
|
||||||
|
<Field label="Quelltyp" value={p.sourceType} />
|
||||||
|
<Field label="Quellenbezeichnung" value={p.sourceLabel} />
|
||||||
|
<Field label="Letzte Quellenaktualisierung" value={p.sourceUpdatedAt ? new Date(p.sourceUpdatedAt).toLocaleDateString('de-CH') : undefined} />
|
||||||
|
<Field label="Letzte Verifikation" value={p.dataQuality.lastVerifiedAt ? new Date(p.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH') : undefined} />
|
||||||
|
</FieldGrid>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">Frische:</Typography>
|
||||||
|
<Chip label={p.dataQuality.freshness} size="small" color={freshnessColor as 'success' | 'warning' | 'error'} />
|
||||||
|
</Box>
|
||||||
|
{p.sourceUrl && (
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
endIcon={<ExternalLink size={13} />}
|
||||||
|
href={p.sourceUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
sx={{ textTransform: 'none' }}
|
||||||
|
>
|
||||||
|
Originalquelle öffnen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{p.sourceMeta && (
|
||||||
|
<>
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
<SectionTitle title="Provenance-Details" />
|
||||||
|
<FieldGrid>
|
||||||
|
<Field label="Externe ID" value={p.sourceMeta.externalId} />
|
||||||
|
<Field label="Quelle Label" value={p.sourceMeta.sourceLabel} />
|
||||||
|
</FieldGrid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SignalsPanel({ signals }: { signals: FutureSignal[] }) {
|
||||||
|
if (signals.length === 0) {
|
||||||
|
return <Typography variant="body2" color="text.secondary">Keine Zukunftssignale für dieses Objekt.</Typography>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<SectionTitle title={`${signals.length} Zukunftssignale`} />
|
||||||
|
{signals.map(s => (
|
||||||
|
<Box key={s.id} sx={{ p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 1, mb: 1.5 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.25 }}>
|
||||||
|
{s.title ?? s.signalType}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||||
|
{s.locationHint} · Konfidenz {Math.round(s.confidenceScore * 100)}% · {s.timeHorizonMonths} Monate
|
||||||
|
</Typography>
|
||||||
|
{s.disclaimer && (
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.disabled', display: 'block', mt: 0.5 }}>
|
||||||
|
{s.disclaimer}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main component ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TABS = ['Übersicht', 'Hard Facts', 'Soft Factors', 'Matchability', 'Datenqualität', 'Quelle', 'Signale'] as const
|
||||||
|
|
||||||
|
export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewProps) {
|
||||||
|
const [tab, setTab] = useState(0)
|
||||||
|
const { data: property, isLoading } = usePropertyById(propertyId)
|
||||||
|
const { data: matches = [] } = usePropertyMatches(propertyId)
|
||||||
|
const { data: signals = [] } = usePropertySignals(propertyId)
|
||||||
|
|
||||||
|
if (isLoading) return <PropertyDetailSkeleton />
|
||||||
|
if (!property) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Alert severity="error">Objekt nicht gefunden.</Alert>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLowQuality = property.dataQuality.score < 0.6
|
||||||
|
const hasCriticalGaps = property.dataQuality.missingCriticalFields.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box sx={{ p: 2.5, borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap' }}>
|
||||||
|
<Chip label={getAssetTypeLabel(property.assetType)} size="small" sx={{ bgcolor: getAssetTypeColor(property.assetType), color: 'white', fontSize: '0.68rem' }} />
|
||||||
|
<Chip label={getResultTypeLabel(property.resultType)} size="small" sx={{ bgcolor: getResultTypeColor(property.resultType), color: 'white', fontSize: '0.68rem' }} />
|
||||||
|
<Chip label={getAvailabilityLabel(property.availabilityStatus)} size="small" color={getAvailabilityChipColor(property.availabilityStatus)} variant="outlined" />
|
||||||
|
</Box>
|
||||||
|
{onClose && (
|
||||||
|
<IconButton size="small" onClick={onClose} sx={{ flexShrink: 0, ml: 1 }}>
|
||||||
|
<X size={16} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 600, lineHeight: 1.3, mb: 0.25 }}>
|
||||||
|
{property.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{property.address.street} {property.address.houseNumber}, {property.address.postalCode} {property.address.city}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{isLowQuality && (
|
||||||
|
<Alert severity="warning" sx={{ mt: 1, py: 0.25, fontSize: '0.75rem' }}>
|
||||||
|
Niedrige Datenqualität ({Math.round(property.dataQuality.score * 100)}%) — Angaben können unvollständig sein.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{hasCriticalGaps && !isLowQuality && (
|
||||||
|
<Alert severity="warning" sx={{ mt: 1, py: 0.25, fontSize: '0.75rem' }}>
|
||||||
|
Kritische Felder fehlen: {property.dataQuality.missingCriticalFields.slice(0, 3).join(', ')}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<Tabs
|
||||||
|
value={tab}
|
||||||
|
onChange={(_, v) => setTab(v)}
|
||||||
|
variant="scrollable"
|
||||||
|
scrollButtons="auto"
|
||||||
|
sx={{
|
||||||
|
borderBottom: '1px solid #e2e8f0',
|
||||||
|
flexShrink: 0,
|
||||||
|
'& .MuiTab-root': { textTransform: 'none', fontSize: '0.8rem', minWidth: 'auto', px: 1.5 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{TABS.map(label => (
|
||||||
|
<Tab key={label} label={label} />
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
<Box sx={{ flex: 1, overflowY: 'auto', p: 2.5 }}>
|
||||||
|
{tab === 0 && <OverviewPanel p={property} />}
|
||||||
|
{tab === 1 && <HardFactsPanel p={property} />}
|
||||||
|
{tab === 2 && <SoftFactorsPanel p={property} />}
|
||||||
|
{tab === 3 && <MatchabilityPanel matches={matches} />}
|
||||||
|
{tab === 4 && <DataQualityPanel p={property} />}
|
||||||
|
{tab === 5 && <SourcePanel p={property} />}
|
||||||
|
{tab === 6 && <SignalsPanel signals={signals} />}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Chip,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { AssetType, AvailabilityStatus } from '../../domain/enums'
|
||||||
|
import { getAssetTypeLabel, getAvailabilityLabel } from './propertyHelpers'
|
||||||
|
|
||||||
|
export interface PropertyTableFilters {
|
||||||
|
search?: string
|
||||||
|
assetTypes?: string[]
|
||||||
|
availabilityStatus?: string
|
||||||
|
sortBy?: 'dataQuality' | 'availability' | 'area' | 'rent' | 'confidence'
|
||||||
|
sortDir?: 'asc' | 'desc'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PropertyFilterBarProps {
|
||||||
|
filters: PropertyTableFilters
|
||||||
|
onFiltersChange: (f: PropertyTableFilters) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SORT_OPTIONS = [
|
||||||
|
{ value: 'dataQuality', label: 'Datenqualität' },
|
||||||
|
{ value: 'area', label: 'Fläche' },
|
||||||
|
{ value: 'rent', label: 'Miete' },
|
||||||
|
{ value: 'confidence', label: 'Konfidenz' },
|
||||||
|
{ value: 'availability', label: 'Verfügbarkeit' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const ALL_ASSET_TYPES = Object.values(AssetType)
|
||||||
|
const ALL_AVAILABILITY = Object.values(AvailabilityStatus)
|
||||||
|
|
||||||
|
export function PropertyFilterBar({ filters, onFiltersChange }: PropertyFilterBarProps) {
|
||||||
|
const isActive =
|
||||||
|
!!filters.search ||
|
||||||
|
(filters.assetTypes?.length ?? 0) > 0 ||
|
||||||
|
!!filters.availabilityStatus ||
|
||||||
|
!!filters.sortBy
|
||||||
|
|
||||||
|
function update(partial: Partial<PropertyTableFilters>) {
|
||||||
|
onFiltersChange({ ...filters, ...partial })
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
onFiltersChange({})
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedAssetTypes = filters.assetTypes ?? []
|
||||||
|
|
||||||
|
function toggleAssetType(type: string) {
|
||||||
|
const current = selectedAssetTypes
|
||||||
|
if (current.includes(type)) {
|
||||||
|
update({ assetTypes: current.filter(t => t !== type) })
|
||||||
|
} else {
|
||||||
|
update({ assetTypes: [...current, type] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card sx={{ mx: 3, mb: 0, mt: 0, borderRadius: '0 0 8px 8px', borderTop: 'none', boxShadow: 'none', border: '1px solid #e2e8f0' }}>
|
||||||
|
<Box sx={{ p: 1.5, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
|
|
||||||
|
{/* Row 1: Asset type chips */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary', mr: 0.5, whiteSpace: 'nowrap' }}>
|
||||||
|
Typ:
|
||||||
|
</Typography>
|
||||||
|
{ALL_ASSET_TYPES.map(t => (
|
||||||
|
<Chip
|
||||||
|
key={t}
|
||||||
|
label={getAssetTypeLabel(t)}
|
||||||
|
size="small"
|
||||||
|
variant={selectedAssetTypes.includes(t) ? 'filled' : 'outlined'}
|
||||||
|
onClick={() => toggleAssetType(t)}
|
||||||
|
sx={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
...(selectedAssetTypes.includes(t) && { bgcolor: '#1e3a5f', color: 'white', borderColor: '#1e3a5f' }),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Row 2: Availability, Sort, Search, Reset */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
|
||||||
|
<Select
|
||||||
|
value={filters.availabilityStatus ?? ''}
|
||||||
|
onChange={e => update({ availabilityStatus: e.target.value || undefined })}
|
||||||
|
displayEmpty
|
||||||
|
size="small"
|
||||||
|
sx={{ minWidth: 160 }}
|
||||||
|
>
|
||||||
|
<MenuItem value="">Verfügbarkeit (alle)</MenuItem>
|
||||||
|
{ALL_AVAILABILITY.map(s => (
|
||||||
|
<MenuItem key={s} value={s}>{getAvailabilityLabel(s)}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={filters.sortBy ?? ''}
|
||||||
|
onChange={e => update({ sortBy: (e.target.value as PropertyTableFilters['sortBy']) || undefined })}
|
||||||
|
displayEmpty
|
||||||
|
size="small"
|
||||||
|
sx={{ minWidth: 160 }}
|
||||||
|
>
|
||||||
|
<MenuItem value="">Sortierung</MenuItem>
|
||||||
|
{SORT_OPTIONS.map(opt => (
|
||||||
|
<MenuItem key={opt.value} value={opt.value}>{opt.label}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
value={filters.search ?? ''}
|
||||||
|
onChange={e => update({ search: e.target.value || undefined })}
|
||||||
|
placeholder="Titel, Stadt, Strasse …"
|
||||||
|
size="small"
|
||||||
|
sx={{ flex: 1, minWidth: 220 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isActive && (
|
||||||
|
<Button size="small" variant="outlined" onClick={reset} sx={{ textTransform: 'none', whiteSpace: 'nowrap' }}>
|
||||||
|
Filter zurücksetzen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,3 +8,10 @@ export { ReviewTaskWidget } from './ReviewTaskWidget'
|
|||||||
export { QuickActionPanel } from './QuickActionPanel'
|
export { QuickActionPanel } from './QuickActionPanel'
|
||||||
export { DashboardSkeleton } from './DashboardSkeleton'
|
export { DashboardSkeleton } from './DashboardSkeleton'
|
||||||
export { DashboardHeader } from './DashboardHeader'
|
export { DashboardHeader } from './DashboardHeader'
|
||||||
|
export { PropertyCard } from './PropertyCard'
|
||||||
|
export type { PropertyCardProps } from './PropertyCard'
|
||||||
|
export { PropertyTable } from './PropertyTable'
|
||||||
|
export { PropertyFilterBar } from './PropertyFilterBar'
|
||||||
|
export type { PropertyTableFilters } from './PropertyFilterBar'
|
||||||
|
export { PropertyDetailView } from './PropertyDetailView'
|
||||||
|
export { PropertyDetailSkeleton } from './PropertyDetailSkeleton'
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import type { AssetType, AvailabilityStatus, ResultType } from '../../domain/enums'
|
||||||
|
|
||||||
|
export function getAssetTypeLabel(type: AssetType): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
OFFICE: 'Büro',
|
||||||
|
LOGISTICS: 'Logistik',
|
||||||
|
RETAIL: 'Retail',
|
||||||
|
GASTRO: 'Gastro',
|
||||||
|
PRODUCTION: 'Produktion',
|
||||||
|
MIXED: 'Gemischt',
|
||||||
|
LIGHT_INDUSTRIAL: 'Leichtindustrie',
|
||||||
|
UNKNOWN: 'Unbekannt',
|
||||||
|
}
|
||||||
|
return labels[type] ?? type
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAssetTypeColor(type: AssetType): string {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
OFFICE: '#1e3a5f',
|
||||||
|
LOGISTICS: '#d97706',
|
||||||
|
RETAIL: '#7c3aed',
|
||||||
|
GASTRO: '#0d9488',
|
||||||
|
PRODUCTION: '#92400e',
|
||||||
|
MIXED: '#6b7280',
|
||||||
|
LIGHT_INDUSTRIAL: '#b45309',
|
||||||
|
UNKNOWN: '#9ca3af',
|
||||||
|
}
|
||||||
|
return colors[type] ?? '#6b7280'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAvailabilityLabel(status: AvailabilityStatus): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
AVAILABLE_NOW: 'Verfügbar',
|
||||||
|
AVAILABLE_SOON: 'Bald verfügbar',
|
||||||
|
FUTURE_SIGNAL: 'Zukunftssignal',
|
||||||
|
OCCUPIED: 'Belegt',
|
||||||
|
UNKNOWN: 'Unbekannt',
|
||||||
|
}
|
||||||
|
return labels[status] ?? status
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAvailabilityChipColor(status: AvailabilityStatus): 'success' | 'warning' | 'secondary' | 'error' | 'default' {
|
||||||
|
const colors: Record<string, 'success' | 'warning' | 'secondary' | 'error' | 'default'> = {
|
||||||
|
AVAILABLE_NOW: 'success',
|
||||||
|
AVAILABLE_SOON: 'warning',
|
||||||
|
FUTURE_SIGNAL: 'secondary',
|
||||||
|
OCCUPIED: 'error',
|
||||||
|
UNKNOWN: 'default',
|
||||||
|
}
|
||||||
|
return colors[status] ?? 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getResultTypeLabel(type: ResultType): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
VERIFIED_PORTFOLIO: 'Portfolio',
|
||||||
|
EXTERNAL_MARKET: 'Markt',
|
||||||
|
FUTURE_AVAILABILITY: 'Zukunft',
|
||||||
|
}
|
||||||
|
return labels[type] ?? type
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getResultTypeColor(type: ResultType): string {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
VERIFIED_PORTFOLIO: '#1e3a5f',
|
||||||
|
EXTERNAL_MARKET: '#1a7a4a',
|
||||||
|
FUTURE_AVAILABILITY: '#7c3aed',
|
||||||
|
}
|
||||||
|
return colors[type] ?? '#6b7280'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function qualityColor(score: number): 'success' | 'warning' | 'error' {
|
||||||
|
if (score >= 0.8) return 'success'
|
||||||
|
if (score >= 0.6) return 'warning'
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confidenceColor(score: number): string {
|
||||||
|
if (score >= 0.85) return '#1a7a4a'
|
||||||
|
if (score >= 0.65) return '#1e3a5f'
|
||||||
|
return '#d97706'
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { propertyService } from '../services/propertyService'
|
import { propertyService } from '../services/propertyService'
|
||||||
|
import { matchService } from '../services/matchService'
|
||||||
|
import { futureSignalService } from '../services/futureSignalService'
|
||||||
import type { AssetType, ResultType } from '../domain/enums'
|
import type { AssetType, ResultType } from '../domain/enums'
|
||||||
import { STALE_PROPERTIES } from '../lib/constants'
|
import { STALE_PROPERTIES, STALE_MATCHES, STALE_SIGNALS } from '../lib/constants'
|
||||||
|
|
||||||
interface PropertyFilter {
|
interface PropertyFilter {
|
||||||
assetType?: AssetType
|
assetType?: AssetType
|
||||||
@@ -32,3 +34,33 @@ export function useProperty(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const usePropertyDetail = useProperty
|
export const usePropertyDetail = useProperty
|
||||||
|
|
||||||
|
export function usePropertyById(id: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['property', id],
|
||||||
|
queryFn: () => propertyService.getById(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
staleTime: STALE_PROPERTIES,
|
||||||
|
select: (res) => res.data ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePropertyMatches(propertyId: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['property-matches', propertyId],
|
||||||
|
queryFn: () => matchService.getMatchesForProperty(propertyId!),
|
||||||
|
enabled: !!propertyId,
|
||||||
|
staleTime: STALE_MATCHES,
|
||||||
|
select: (res) => res.data ?? [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePropertySignals(propertyId: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['property-signals', propertyId],
|
||||||
|
queryFn: () => futureSignalService.getSignalsForProperty(propertyId!),
|
||||||
|
enabled: !!propertyId,
|
||||||
|
staleTime: STALE_SIGNALS,
|
||||||
|
select: (res) => res.data ?? [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+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 { useState } from 'react'
|
||||||
import { EmptyState, ErrorState, LoadingPage } from '../../components/ui'
|
import { Box } from '@mui/material'
|
||||||
import { propertyService } from '../../services/propertyService'
|
import { PageHeader } from '../../components/layout'
|
||||||
import { AssetType, AvailabilityStatus, ResultType } from '../../domain/enums'
|
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 {
|
function applyFilters(properties: Property[], filters: PropertyTableFilters): Property[] {
|
||||||
switch (type) {
|
let result = [...properties]
|
||||||
case AssetType.OFFICE: return 'Büro'
|
|
||||||
case AssetType.LOGISTICS: return 'Logistik'
|
if (filters.search) {
|
||||||
case AssetType.RETAIL: return 'Retail'
|
const q = filters.search.toLowerCase()
|
||||||
case AssetType.GASTRO: return 'Gastro'
|
result = result.filter(
|
||||||
case AssetType.PRODUCTION: return 'Produktion'
|
p =>
|
||||||
case AssetType.MIXED: return 'Gemischt'
|
p.title.toLowerCase().includes(q) ||
|
||||||
case AssetType.LIGHT_INDUSTRIAL: return 'Leichtindustrie'
|
p.location.city.toLowerCase().includes(q) ||
|
||||||
case AssetType.UNKNOWN: return 'Unbekannt'
|
p.address.street.toLowerCase().includes(q),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function getAssetTypeColor(type: AssetType): string {
|
if (filters.assetTypes && filters.assetTypes.length > 0) {
|
||||||
switch (type) {
|
result = result.filter(p => filters.assetTypes!.includes(p.assetType))
|
||||||
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'
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function getAvailabilityLabel(status: AvailabilityStatus): string {
|
if (filters.availabilityStatus) {
|
||||||
switch (status) {
|
result = result.filter(p => p.availabilityStatus === filters.availabilityStatus)
|
||||||
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'
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function getAvailabilityColor(status: AvailabilityStatus): 'success' | 'warning' | 'secondary' | 'error' | 'default' {
|
if (filters.sortBy) {
|
||||||
switch (status) {
|
const dir = filters.sortDir === 'asc' ? 1 : -1
|
||||||
case AvailabilityStatus.AVAILABLE_NOW: return 'success'
|
result.sort((a, b) => {
|
||||||
case AvailabilityStatus.AVAILABLE_SOON: return 'warning'
|
switch (filters.sortBy) {
|
||||||
case AvailabilityStatus.FUTURE_SIGNAL: return 'secondary'
|
case 'dataQuality': return dir * (a.dataQuality.score - b.dataQuality.score)
|
||||||
case AvailabilityStatus.OCCUPIED: return 'error'
|
case 'area': return dir * (a.areaSqm - b.areaSqm)
|
||||||
case AvailabilityStatus.UNKNOWN: return 'default'
|
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 {
|
return result
|
||||||
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'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Properties() {
|
export default function Properties() {
|
||||||
const [selectedResultType, setSelectedResultType] = useState<ResultType | 'ALL'>('ALL')
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
const [selectedAssetType, setSelectedAssetType] = useState<AssetType | 'ALL'>('ALL')
|
const [filters, setFilters] = useState<PropertyTableFilters>({})
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
|
||||||
|
|
||||||
const { data: resp, isLoading, error } = useQuery({
|
const { data: properties = [], isLoading, isError } = useProperties()
|
||||||
queryKey: ['properties'],
|
const filtered = applyFilters(properties, filters)
|
||||||
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' },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
{/* Page Header */}
|
<PageHeader
|
||||||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid', borderColor: 'divider', px: 3, py: 2 }} className="flex items-center justify-between">
|
title="Objektverwaltung"
|
||||||
<Box className="flex items-center gap-2">
|
subtitle={`${filtered.length} von ${properties.length} Objekten`}
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700 }} color="text.primary">Objekte</Typography>
|
/>
|
||||||
<Chip label={properties.length} size="small" sx={{ bgcolor: '#1e3a5f', color: 'white', fontWeight: 700 }} />
|
<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>
|
</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 */}
|
{/* Detail panel */}
|
||||||
<Box sx={{ px: 3, py: 3 }} className="flex flex-col gap-3">
|
{selectedId && (
|
||||||
|
<Box
|
||||||
{/* Filter Bar */}
|
sx={{
|
||||||
<Card sx={{ elevation: 1, p: 1.5 }}>
|
width: 480,
|
||||||
<Box className="flex flex-col gap-2">
|
flexShrink: 0,
|
||||||
{/* Row 1: Source type chips */}
|
borderLeft: '1px solid #e2e8f0',
|
||||||
<Box className="flex items-center gap-2 flex-wrap">
|
overflow: 'hidden',
|
||||||
{sourceTypeFilters.map(f => (
|
display: 'flex',
|
||||||
<Chip
|
flexDirection: 'column',
|
||||||
key={f.value}
|
}}
|
||||||
label={f.label}
|
>
|
||||||
variant={selectedResultType === f.value ? 'filled' : 'outlined'}
|
<PropertyDetailView propertyId={selectedId} onClose={() => setSelectedId(null)} />
|
||||||
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>
|
|
||||||
</Box>
|
</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>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ export const futureSignalService = {
|
|||||||
return { data }
|
return { data }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getSignalsForProperty(propertyId: string): Promise<ListResponse<FutureSignal>> {
|
||||||
|
const data = await provider.getByProperty(propertyId)
|
||||||
|
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||||
|
},
|
||||||
|
|
||||||
async getSignalSummary(): Promise<FutureSignalSummary> {
|
async getSignalSummary(): Promise<FutureSignalSummary> {
|
||||||
const signals = await provider.getAll()
|
const signals = await provider.getAll()
|
||||||
const RESTRICTED: readonly string[] = ['CONFIDENTIAL', 'INTERNAL']
|
const RESTRICTED: readonly string[] = ['CONFIDENTIAL', 'INTERNAL']
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ export const matchService = {
|
|||||||
return { data }
|
return { data }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getMatchesForProperty(propertyId: string): Promise<ListResponse<Match>> {
|
||||||
|
const data = await provider.getByProperty(propertyId)
|
||||||
|
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||||
|
},
|
||||||
|
|
||||||
async getStrongMatches(minScore = 80): Promise<StrongMatchItem[]> {
|
async getStrongMatches(minScore = 80): Promise<StrongMatchItem[]> {
|
||||||
const [matches, properties] = await Promise.all([
|
const [matches, properties] = await Promise.all([
|
||||||
provider.getAll(),
|
provider.getAll(),
|
||||||
|
|||||||
@@ -36,4 +36,9 @@ export const propertyService = {
|
|||||||
active: data.filter(p => ACTIVE_STATUSES.includes(p.availabilityStatus)).length,
|
active: data.filter(p => ACTIVE_STATUSES.includes(p.availabilityStatus)).length,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getProperties(filters?: PropertyFilters): Promise<ListResponse<Property>> {
|
||||||
|
const data = await provider.getAll(filters)
|
||||||
|
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user