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