0b874865ba
- need.ts: add SizeRange, MustHaveCriterion, WeightedPreference types; extend Need with desiredLocation, sizeRange, mustHaveCriteria, weightedPreferences, status - futureSignal.ts: add SignalEvidence type; extend FutureSignal with title, evidence, matchabilityScore, reviewStatus - aiOutput.ts: new AIOutput entity with type, inputHash, outputJson, provider/model/promptVersion/schemaVersion, reviewStatus - activityEvent.ts: new ActivityEvent entity with actorId, action, entityType, entityId, timestamp, metadata - unifiedResult.ts: discriminated union (VerifiedPortfolioResult | ExternalMarketResult | FutureAvailabilityResult) - index.ts: export all three new files - Properties.tsx: add LIGHT_INDUSTRIAL + UNKNOWN cases to exhaustive switches Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
378 lines
16 KiB
TypeScript
378 lines
16 KiB
TypeScript
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'
|
|
|
|
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 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'
|
|
}
|
|
}
|
|
|
|
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'
|
|
}
|
|
}
|
|
|
|
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'
|
|
}
|
|
}
|
|
|
|
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'
|
|
}
|
|
|
|
export default function Properties() {
|
|
const [selectedResultType, setSelectedResultType] = useState<ResultType | 'ALL'>('ALL')
|
|
const [selectedAssetType, setSelectedAssetType] = useState<AssetType | 'ALL'>('ALL')
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
|
|
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' },
|
|
]
|
|
|
|
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>
|
|
<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>
|
|
</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>
|
|
)
|
|
}
|