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 { DashboardSkeleton } from './DashboardSkeleton'
|
||||
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 { propertyService } from '../services/propertyService'
|
||||
import { matchService } from '../services/matchService'
|
||||
import { futureSignalService } from '../services/futureSignalService'
|
||||
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 {
|
||||
assetType?: AssetType
|
||||
@@ -32,3 +34,33 @@ export function useProperty(id: string) {
|
||||
}
|
||||
|
||||
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 ?? [],
|
||||
})
|
||||
}
|
||||
|
||||
+67
-350
@@ -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={{
|
||||
|
||||
{/* Detail panel */}
|
||||
{selectedId && (
|
||||
<Box
|
||||
sx={{
|
||||
width: 480,
|
||||
flexShrink: 0,
|
||||
borderLeft: '1px solid #e2e8f0',
|
||||
overflow: 'hidden',
|
||||
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,
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Neues Objekt
|
||||
</button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<PropertyDetailView propertyId={selectedId} onClose={() => setSelectedId(null)} />
|
||||
</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>
|
||||
</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>
|
||||
|
||||
@@ -24,6 +24,11 @@ export const futureSignalService = {
|
||||
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> {
|
||||
const signals = await provider.getAll()
|
||||
const RESTRICTED: readonly string[] = ['CONFIDENTIAL', 'INTERNAL']
|
||||
|
||||
@@ -29,6 +29,11 @@ export const matchService = {
|
||||
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[]> {
|
||||
const [matches, properties] = await Promise.all([
|
||||
provider.getAll(),
|
||||
|
||||
@@ -36,4 +36,9 @@ export const propertyService = {
|
||||
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