Initial commit

This commit is contained in:
Benjamin Sutter
2026-05-15 00:48:18 +02:00
commit 9e827c50f9
72 changed files with 10477 additions and 0 deletions
+374
View File
@@ -0,0 +1,374 @@
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'
}
}
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'
}
}
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" 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" fontWeight={600}>Objekt</Typography></TableCell>
<TableCell><Typography variant="caption" fontWeight={600}>Typ</Typography></TableCell>
<TableCell><Typography variant="caption" fontWeight={600}>Standort</Typography></TableCell>
<TableCell><Typography variant="caption" fontWeight={600}>Fläche</Typography></TableCell>
<TableCell><Typography variant="caption" fontWeight={600}>Miete/m²</Typography></TableCell>
<TableCell><Typography variant="caption" fontWeight={600}>Quelle</Typography></TableCell>
<TableCell><Typography variant="caption" fontWeight={600}>Konfidenz</Typography></TableCell>
<TableCell><Typography variant="caption" fontWeight={600}>Datenqualität</Typography></TableCell>
<TableCell><Typography variant="caption" fontWeight={600}>Verfügbarkeit</Typography></TableCell>
<TableCell><Typography variant="caption" 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" 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"
fontWeight={600}
sx={{ 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" fontWeight={600}>Kritische Felder fehlen:</Typography>
{property.dataQuality.missingCriticalFields.map(f => (
<Typography key={f} variant="caption" display="block"> {f}</Typography>
))}
</Box>
)}
{property.dataQuality.warnings.length > 0 && (
<Box mt={0.5}>
<Typography variant="caption" fontWeight={600}>Warnungen:</Typography>
{property.dataQuality.warnings.map((w, i) => (
<Typography key={i} variant="caption" 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)}
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>
)
}