From 3cd2e83b25b5ad3fb4dab0688c9affb64f31adfa Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Fri, 15 May 2026 16:53:51 +0200 Subject: [PATCH] feat: F007 property repository & detail view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/supply/PropertyCard.tsx | 160 ++++++ .../supply/PropertyDetailSkeleton.tsx | 35 ++ src/components/supply/PropertyDetailView.tsx | 498 ++++++++++++++++++ src/components/supply/PropertyFilterBar.tsx | 133 +++++ src/components/supply/PropertyTable.tsx | 271 ++++++++++ src/components/supply/index.ts | 7 + src/components/supply/propertyHelpers.ts | 81 +++ src/hooks/useProperties.ts | 34 +- src/pages/supply/Properties.tsx | 423 +++------------ src/services/futureSignalService.ts | 5 + src/services/matchService.ts | 5 + src/services/propertyService.ts | 5 + 12 files changed, 1303 insertions(+), 354 deletions(-) create mode 100644 src/components/supply/PropertyCard.tsx create mode 100644 src/components/supply/PropertyDetailSkeleton.tsx create mode 100644 src/components/supply/PropertyDetailView.tsx create mode 100644 src/components/supply/PropertyFilterBar.tsx create mode 100644 src/components/supply/PropertyTable.tsx create mode 100644 src/components/supply/propertyHelpers.ts 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 && ( + + + + )} + {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 } } + }, }