diff --git a/src/components/supply/PropertyCard.tsx b/src/components/supply/PropertyCard.tsx
new file mode 100644
index 0000000..abf6c41
--- /dev/null
+++ b/src/components/supply/PropertyCard.tsx
@@ -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 (
+
+
+ {/* Header Zone */}
+
+
+
+
+ {isStale && }
+ {isLowConfidence && }
+
+
+ {/* Primary Zone */}
+
+ {p.title}
+
+
+ {p.address.street} {p.address.houseNumber}, {p.address.city}
+ {p.location.canton ? ` · ${p.location.canton}` : ''}
+
+
+
+ Fläche
+ {p.areaSqm.toLocaleString('de-CH')} m²
+
+
+ Miete/m²/Jahr
+
+ {p.rentPricePerSqm > 0 ? `CHF ${p.rentPricePerSqm}` : k.A.}
+
+
+
+ Verfügbar ab
+
+ {p.availabilityDate ? new Date(p.availabilityDate).toLocaleDateString('de-CH') : '–'}
+
+
+
+
+ {/* Matchability Zone */}
+ {matchScore !== undefined ? (
+
+
+ = 80 ? '#1a7a4a' : matchScore >= 60 ? '#d97706' : '#c0392b', color: 'white' }}
+ />
+
+ {positiveFactors && positiveFactors.length > 0 && (
+
+ {positiveFactors.slice(0, 3).map((f, i) => (
+ ✓ {f}
+ ))}
+
+ )}
+ {topTradeoff && (
+ ⚠ {topTradeoff}
+ )}
+
+ ) : (
+
+
+ Bedarfsprofil wählen für Matchbarkeit
+
+
+ )}
+
+ {/* Data Quality Zone */}
+
+
+ Datenqualität
+ {Math.round(p.dataQuality.score * 100)}%
+
+
+ {p.dataQuality.missingCriticalFields.length > 0 && (
+
+ {p.dataQuality.missingCriticalFields.slice(0, 3).map(f => (
+
+ ))}
+ {p.dataQuality.missingCriticalFields.length > 3 && (
+ +{p.dataQuality.missingCriticalFields.length - 3} mehr
+ )}
+
+ )}
+
+
+ {/* Action Zone */}
+ e.stopPropagation()}>
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/supply/PropertyDetailSkeleton.tsx b/src/components/supply/PropertyDetailSkeleton.tsx
new file mode 100644
index 0000000..641b28d
--- /dev/null
+++ b/src/components/supply/PropertyDetailSkeleton.tsx
@@ -0,0 +1,35 @@
+import { Box, Skeleton, Tab, Tabs } from '@mui/material'
+
+export function PropertyDetailSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {['Übersicht', 'Hard Facts', 'Soft Factors'].map(label => (
+
+ ))}
+
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/supply/PropertyDetailView.tsx b/src/components/supply/PropertyDetailView.tsx
new file mode 100644
index 0000000..2c521a4
--- /dev/null
+++ b/src/components/supply/PropertyDetailView.tsx
@@ -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 (
+
+
+ {label}
+
+ {value !== undefined && value !== null && value !== '' ? (
+
+ {typeof value === 'boolean' ? (value ? 'Ja' : 'Nein') : String(value)}
+
+ ) : (
+ —
+ )}
+
+ )
+}
+
+function FieldGrid({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function SectionTitle({ title }: { title: string }) {
+ return (
+
+ {title}
+
+ )
+}
+
+function ScoreRow({ label, value }: { label: string; value?: number }) {
+ if (value === undefined || value === null) {
+ return (
+
+
+ {label}
+ —
+
+
+
+ )
+ }
+ const pct = value > 1 ? value : value * 100
+ return (
+
+
+ {label}
+ {Math.round(pct)}
+
+ = 70 ? 'success' : pct >= 40 ? 'warning' : 'error'}
+ sx={{ height: 4, borderRadius: 2 }}
+ />
+
+ )
+}
+
+// ── Tab panels ────────────────────────────────────────────────────────────────
+
+function OverviewPanel({ p }: { p: Property }) {
+ return (
+
+
+ {[
+ { 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 }) => (
+
+ {label}
+
+ {value ?? k.A.}
+
+
+ ))}
+
+ {p.description && (
+ <>
+
+
+ {p.description}
+
+ >
+ )}
+
+
+ Score
+ {Math.round(p.dataQuality.score * 100)}%
+
+
+ {p.dataQuality.warnings.length > 0 && (
+
+ {p.dataQuality.warnings.map((w, i) => (
+ ⚠ {w}
+ ))}
+
+ )}
+
+ )
+}
+
+function HardFactsPanel({ p }: { p: Property }) {
+ const hf = p.hardFacts
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0 ? `CHF ${p.rentPricePerSqm}` : undefined} />
+
+
+
+
+
+
+
+
+
+
+
+
+ {hf && (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ )
+}
+
+function SoftFactorsPanel({ p }: { p: Property }) {
+ const sf = p.softFactors
+ if (!sf) {
+ return Keine Soft Factors vorhanden.
+ }
+ 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 (
+
+
+
+
+
+
+
+
+
+
+
+ {sf.parkingSpots !== undefined && (
+
+ )}
+ {sf.publicTransportMinutes !== undefined && (
+
+ )}
+ {sf.infrastructureNotes && (
+
+ )}
+
+ )
+}
+
+function MatchabilityPanel({ matches }: { matches: Match[] }) {
+ if (matches.length === 0) {
+ return (
+
+ Keine Matches für dieses Objekt vorhanden.
+
+ )
+ }
+ return (
+
+
+ {matches.map(m => (
+
+
+ Bedarf: {m.needId}
+ = 80 ? '#1a7a4a' : m.matchScore >= 60 ? '#d97706' : '#c0392b',
+ color: 'white',
+ }}
+ />
+
+ {m.positiveFactors?.slice(0, 3).map((f, i) => (
+ ✓ {f.explanation ?? f.criterion}
+ ))}
+ {m.missingData && m.missingData.length > 0 && (
+
+ ⚠ {m.missingData.length} fehlende Datenfelder
+
+ )}
+
+ ))}
+
+ )
+}
+
+function DataQualityPanel({ p }: { p: Property }) {
+ return (
+
+
+
+ Gesamtscore
+ {Math.round(p.dataQuality.score * 100)}%
+
+
+
+ {p.dataQuality.missingCriticalFields.length > 0 && (
+ <>
+
+
+ {p.dataQuality.missingCriticalFields.map(f => (
+
+ ))}
+
+ >
+ )}
+
+ {p.dataQuality.missingOptionalFields.length > 0 && (
+ <>
+
+
+ {p.dataQuality.missingOptionalFields.map(f => (
+
+ ))}
+
+ >
+ )}
+
+ {p.dataQuality.warnings.length > 0 && (
+ <>
+
+ {p.dataQuality.warnings.map((w, i) => (
+ {w}
+ ))}
+ >
+ )}
+
+
+
+
+ )
+}
+
+function SourcePanel({ p }: { p: Property }) {
+ const freshnessColor = p.dataQuality.freshness === FreshnessStatus.FRESH
+ ? 'success'
+ : p.dataQuality.freshness === FreshnessStatus.STALE
+ ? 'warning'
+ : 'error'
+
+ return (
+
+
+
+
+
+
+
+
+
+ Frische:
+
+
+ {p.sourceUrl && (
+
+ }
+ href={p.sourceUrl}
+ target="_blank"
+ rel="noopener noreferrer"
+ sx={{ textTransform: 'none' }}
+ >
+ Originalquelle öffnen
+
+
+ )}
+ {p.sourceMeta && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+ )
+}
+
+function SignalsPanel({ signals }: { signals: FutureSignal[] }) {
+ if (signals.length === 0) {
+ return Keine Zukunftssignale für dieses Objekt.
+ }
+ return (
+
+
+ {signals.map(s => (
+
+
+ {s.title ?? s.signalType}
+
+
+ {s.locationHint} · Konfidenz {Math.round(s.confidenceScore * 100)}% · {s.timeHorizonMonths} Monate
+
+ {s.disclaimer && (
+
+ {s.disclaimer}
+
+ )}
+
+ ))}
+
+ )
+}
+
+// ── 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
+ if (!property) {
+ return (
+
+ Objekt nicht gefunden.
+
+ )
+ }
+
+ const isLowQuality = property.dataQuality.score < 0.6
+ const hasCriticalGaps = property.dataQuality.missingCriticalFields.length > 0
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+ {onClose && (
+
+
+
+ )}
+
+
+ {property.title}
+
+
+ {property.address.street} {property.address.houseNumber}, {property.address.postalCode} {property.address.city}
+
+
+ {isLowQuality && (
+
+ Niedrige Datenqualität ({Math.round(property.dataQuality.score * 100)}%) — Angaben können unvollständig sein.
+
+ )}
+ {hasCriticalGaps && !isLowQuality && (
+
+ Kritische Felder fehlen: {property.dataQuality.missingCriticalFields.slice(0, 3).join(', ')}
+
+ )}
+
+
+ {/* Tabs */}
+ 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 content */}
+
+ {tab === 0 && }
+ {tab === 1 && }
+ {tab === 2 && }
+ {tab === 3 && }
+ {tab === 4 && }
+ {tab === 5 && }
+ {tab === 6 && }
+
+
+ )
+}
diff --git a/src/components/supply/PropertyFilterBar.tsx b/src/components/supply/PropertyFilterBar.tsx
new file mode 100644
index 0000000..2d68893
--- /dev/null
+++ b/src/components/supply/PropertyFilterBar.tsx
@@ -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) {
+ 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 (
+
+
+
+ {/* Row 1: Asset type chips */}
+
+
+ Typ:
+
+ {ALL_ASSET_TYPES.map(t => (
+ toggleAssetType(t)}
+ sx={{
+ cursor: 'pointer',
+ ...(selectedAssetTypes.includes(t) && { bgcolor: '#1e3a5f', color: 'white', borderColor: '#1e3a5f' }),
+ }}
+ />
+ ))}
+
+
+ {/* Row 2: Availability, Sort, Search, Reset */}
+
+
+
+
+
+ update({ search: e.target.value || undefined })}
+ placeholder="Titel, Stadt, Strasse …"
+ size="small"
+ sx={{ flex: 1, minWidth: 220 }}
+ />
+
+ {isActive && (
+
+ )}
+
+
+
+ )
+}
diff --git a/src/components/supply/PropertyTable.tsx b/src/components/supply/PropertyTable.tsx
new file mode 100644
index 0000000..39f08e8
--- /dev/null
+++ b/src/components/supply/PropertyTable.tsx
@@ -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) => (
+
+ {COL_HEADERS.map((h) => (
+
+
+
+ ))}
+
+ ))}
+ >
+ )
+}
+
+export function PropertyTable({
+ properties,
+ isLoading,
+ isError,
+ selectedId,
+ onSelect,
+ onViewDetail,
+ filters,
+ onFiltersChange,
+}: PropertyTableProps) {
+ if (isError) {
+ return Objekte konnten nicht geladen werden.
+ }
+
+ 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 (
+ handleSort(field)}
+ >
+ {label}{isActive ? (filters.sortDir === 'asc' ? ' ↑' : ' ↓') : ''}
+
+ )
+ }
+
+ return (
+
+
+
+
+ Objekt
+ Typ
+ Standort
+
+
+
+
+
+ Quelle
+ Aktionen
+
+
+
+ {isLoading ? (
+
+ ) : properties.length === 0 ? (
+
+
+
+ Keine Objekte gefunden.
+
+
+
+ ) : (
+ properties.map(p => {
+ const isSelected = p.id === selectedId
+ const qScore = p.dataQuality.score
+ const hasCritical = p.dataQuality.missingCriticalFields.length > 0
+
+ return (
+ 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 */}
+
+
+ {p.title}
+
+
+ {p.address.street} {p.address.houseNumber}, {p.address.city}
+
+
+
+ {/* Typ */}
+
+
+
+
+ {/* Standort */}
+
+ {p.location.city}
+ {p.location.canton && (
+ {p.location.canton}
+ )}
+
+
+ {/* Fläche */}
+
+ {p.areaSqm.toLocaleString('de-CH')}
+
+
+ {/* Miete */}
+
+
+ {p.rentPricePerSqm > 0 ? `CHF ${p.rentPricePerSqm}` : k.A.}
+
+
+
+ {/* Verfügbarkeit */}
+
+
+
+
+ {/* Konfidenz */}
+
+ = 0.85 ? '#1a7a4a' : p.confidenceScore >= 0.65 ? '#1e3a5f' : '#d97706',
+ }}
+ >
+ {Math.round(p.confidenceScore * 100)}%
+
+
+
+ {/* Datenqualität */}
+
+
+
+
+
+ {Math.round(qScore * 100)}%
+
+
+
+
+
+ {/* Quelle */}
+
+
+
+
+ {/* Aktionen */}
+ e.stopPropagation()}>
+
+
+ onViewDetail ? onViewDetail(p.id) : onSelect(p.id)}>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+ })
+ )}
+
+
+
+ )
+}
diff --git a/src/components/supply/index.ts b/src/components/supply/index.ts
index d42ad2d..1d61c2b 100644
--- a/src/components/supply/index.ts
+++ b/src/components/supply/index.ts
@@ -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'
diff --git a/src/components/supply/propertyHelpers.ts b/src/components/supply/propertyHelpers.ts
new file mode 100644
index 0000000..db405e2
--- /dev/null
+++ b/src/components/supply/propertyHelpers.ts
@@ -0,0 +1,81 @@
+import type { AssetType, AvailabilityStatus, ResultType } from '../../domain/enums'
+
+export function getAssetTypeLabel(type: AssetType): string {
+ const labels: Record = {
+ 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 = {
+ 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 = {
+ 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 = {
+ 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 = {
+ VERIFIED_PORTFOLIO: 'Portfolio',
+ EXTERNAL_MARKET: 'Markt',
+ FUTURE_AVAILABILITY: 'Zukunft',
+ }
+ return labels[type] ?? type
+}
+
+export function getResultTypeColor(type: ResultType): string {
+ const colors: Record = {
+ 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'
+}
diff --git a/src/hooks/useProperties.ts b/src/hooks/useProperties.ts
index b7739c4..9ede0b7 100644
--- a/src/hooks/useProperties.ts
+++ b/src/hooks/useProperties.ts
@@ -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 ?? [],
+ })
+}
diff --git a/src/pages/supply/Properties.tsx b/src/pages/supply/Properties.tsx
index 5122a48..93d4591 100644
--- a/src/pages/supply/Properties.tsx
+++ b/src/pages/supply/Properties.tsx
@@ -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('ALL')
- const [selectedAssetType, setSelectedAssetType] = useState('ALL')
- const [searchQuery, setSearchQuery] = useState('')
+ const [selectedId, setSelectedId] = useState(null)
+ const [filters, setFilters] = useState({})
- const { data: resp, isLoading, error } = useQuery({
- queryKey: ['properties'],
- queryFn: () => propertyService.getAll(),
- })
-
- if (isLoading) return
- if (error) return
-
- 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 (
-
- {/* Page Header */}
-
-
- Objekte
-
+
+
+
+
+
+ {/* Table */}
+
+
-
-
-
-
-
-
- {/* Content */}
-
-
- {/* Filter Bar */}
-
-
- {/* Row 1: Source type chips */}
-
- {sourceTypeFilters.map(f => (
- 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' }
- }
- />
- ))}
-
- {/* Row 2: Asset type select + search */}
-
-
- setSearchQuery(e.target.value)}
- placeholder="Suche nach Titel, Stadt, Strasse…"
- size="small"
- sx={{ ml: 'auto', minWidth: 260 }}
- />
-
+ {/* Detail panel */}
+ {selectedId && (
+
+ setSelectedId(null)} />
-
-
- {/* Properties Table */}
- {filtered.length === 0 ? (
-
- ) : (
-
-
-
-
- Objekt
- Typ
- Standort
- Fläche
- Miete/m²
- Quelle
- Konfidenz
- Datenqualität
- Verfügbarkeit
- Aktionen
-
-
-
- {filtered.map(property => {
- const hasCritical = property.dataQuality.missingCriticalFields.length > 0
- return (
-
- {/* Objekt */}
-
- {property.title}
-
- {property.address.street} {property.address.houseNumber}, {property.address.city}
-
-
-
- {/* Typ */}
-
-
-
-
- {/* Standort */}
-
- {property.location.city}
- {property.location.canton && (
- {property.location.canton}
- )}
-
-
- {/* Fläche */}
-
- {property.areaSqm.toLocaleString('de-CH')} m²
-
-
- {/* Miete/m² */}
-
- CHF {property.rentPricePerSqm}
-
-
- {/* Quelle */}
-
-
-
-
- {/* Konfidenz */}
-
-
- {Math.round(property.confidenceScore * 100)}%
-
-
-
- {/* Datenqualität */}
-
-
- {property.dataQuality.missingCriticalFields.length > 0 && (
-
- Kritische Felder fehlen:
- {property.dataQuality.missingCriticalFields.map(f => (
- • {f}
- ))}
-
- )}
- {property.dataQuality.warnings.length > 0 && (
-
- Warnungen:
- {property.dataQuality.warnings.map((w, i) => (
- • {w}
- ))}
-
- )}
- {property.dataQuality.missingCriticalFields.length === 0 && property.dataQuality.warnings.length === 0 && (
- Keine Probleme
- )}
-
- }
- >
-
-
-
- {Math.round(property.dataQuality.score * 100)}%
-
-
-
-
-
- {/* Verfügbarkeit */}
-
-
-
-
- {/* Aktionen */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
- })}
-
-
-
)}
diff --git a/src/services/futureSignalService.ts b/src/services/futureSignalService.ts
index da72755..0ecb410 100644
--- a/src/services/futureSignalService.ts
+++ b/src/services/futureSignalService.ts
@@ -24,6 +24,11 @@ export const futureSignalService = {
return { data }
},
+ async getSignalsForProperty(propertyId: string): Promise> {
+ const data = await provider.getByProperty(propertyId)
+ return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
+ },
+
async getSignalSummary(): Promise {
const signals = await provider.getAll()
const RESTRICTED: readonly string[] = ['CONFIDENTIAL', 'INTERNAL']
diff --git a/src/services/matchService.ts b/src/services/matchService.ts
index b9e1973..597c7e3 100644
--- a/src/services/matchService.ts
+++ b/src/services/matchService.ts
@@ -29,6 +29,11 @@ export const matchService = {
return { data }
},
+ async getMatchesForProperty(propertyId: string): Promise> {
+ const data = await provider.getByProperty(propertyId)
+ return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
+ },
+
async getStrongMatches(minScore = 80): Promise {
const [matches, properties] = await Promise.all([
provider.getAll(),
diff --git a/src/services/propertyService.ts b/src/services/propertyService.ts
index 76da58d..028cc16 100644
--- a/src/services/propertyService.ts
+++ b/src/services/propertyService.ts
@@ -36,4 +36,9 @@ export const propertyService = {
active: data.filter(p => ACTIVE_STATUSES.includes(p.availabilityStatus)).length,
}
},
+
+ async getProperties(filters?: PropertyFilters): Promise> {
+ const data = await provider.getAll(filters)
+ return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
+ },
}