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
+320
View File
@@ -0,0 +1,320 @@
import {
Box,
Button,
Card,
Chip,
Typography,
LinearProgress,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
CircularProgress,
Stack,
} from '@mui/material'
import { X } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router'
import { propertyService } from '../../services/propertyService'
import { useCompareStore } from '../../stores/compareStore'
import { EmptyState } from '../../components/ui'
import { ResultType, RiskLevel } from '../../domain/enums'
import type { Property } from '../../domain/property'
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 getRiskColor(risk?: RiskLevel): 'success' | 'warning' | 'error' | 'default' {
if (!risk) return 'default'
if (risk === RiskLevel.LOW) return 'success'
if (risk === RiskLevel.MEDIUM) return 'warning'
return 'error'
}
function getRiskLabel(risk?: RiskLevel): string {
if (!risk) return ''
switch (risk) {
case RiskLevel.LOW: return 'Niedrig'
case RiskLevel.MEDIUM: return 'Mittel'
case RiskLevel.HIGH: return 'Hoch'
case RiskLevel.CRITICAL: return 'Kritisch'
}
}
interface CompareRow {
label: string
getValue: (p: Property) => string | number | null
format?: (v: string | number | null, p: Property) => React.ReactNode
isHigherBetter?: boolean
isLowerBetter?: boolean
}
function NumericCell({ value, isBest }: { value: React.ReactNode; isBest: boolean }) {
return (
<TableCell
sx={{
bgcolor: isBest ? '#f0fdf4' : 'transparent',
fontWeight: isBest ? 700 : 400,
color: isBest ? '#1a7a4a' : 'inherit',
borderLeft: '1px solid #f1f5f9',
}}
>
{value}
</TableCell>
)
}
export default function Compare() {
const navigate = useNavigate()
const { compareTray, removeFromCompare, clearCompare } = useCompareStore()
const { data: propResp, isLoading } = useQuery({
queryKey: ['properties'],
queryFn: () => propertyService.getAll(),
})
const properties = propResp?.data ?? []
const compareProperties = properties.filter(p => compareTray.includes(p.id))
// Keep ordering same as tray
const orderedProperties = compareTray
.map(id => compareProperties.find(p => p.id === id))
.filter((p): p is Property => p !== null)
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 12 }}>
<CircularProgress />
</Box>
)
}
if (compareTray.length === 0) {
return (
<Box>
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2 }}>
<Typography variant="h5" fontWeight={700}>Vergleich</Typography>
</Box>
<Box sx={{ px: 3, py: 3 }}>
<EmptyState
title="Keine Objekte zum Vergleich"
description="Fügen Sie Objekte aus den Suchergebnissen zum Vergleich hinzu."
action={{ label: 'Zur Suche', onClick: () => navigate('/demand/results') }}
/>
</Box>
</Box>
)
}
const rows: CompareRow[] = [
{
label: 'Fläche (m²)',
getValue: p => p.areaSqm,
isHigherBetter: false,
},
{
label: 'Miete/m² (CHF)',
getValue: p => p.rentPricePerSqm,
isLowerBetter: true,
},
{
label: 'Gesamtmiete/Monat (CHF)',
getValue: p => p.totalRentMonthly ?? null,
format: (v) => v != null ? `${Number(v).toLocaleString('de-CH')} CHF` : <em style={{ color: '#94a3b8' }}></em>,
isLowerBetter: true,
},
{
label: 'Verfügbarkeit',
getValue: p => p.availabilityDate,
format: (v) => v ?? <em style={{ color: '#94a3b8' }}></em>,
},
{
label: 'Standort',
getValue: p => `${p.location.city}${p.location.district ? ', ' + p.location.district : ''}`,
},
{
label: 'Datenqualität',
getValue: p => p.dataQuality.score,
format: (v, p) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ width: 80 }}>
<LinearProgress
variant="determinate"
value={p.dataQuality.score * 100}
sx={{ height: 6, borderRadius: 3 }}
color={p.dataQuality.score >= 0.8 ? 'success' : p.dataQuality.score >= 0.6 ? 'warning' : 'error'}
/>
</Box>
<Typography variant="caption">{Math.round(p.dataQuality.score * 100)}%</Typography>
</Box>
),
isHigherBetter: true,
},
{
label: 'Konfidenz',
getValue: p => p.confidenceScore,
format: (v) => v != null ? `${Math.round(Number(v) * 100)}%` : <em style={{ color: '#94a3b8' }}></em>,
isHigherBetter: true,
},
{
label: 'Risiko',
getValue: p => p.riskLevel ?? null,
format: (v, p) => (
<Chip
label={getRiskLabel(p.riskLevel)}
size="small"
color={getRiskColor(p.riskLevel)}
variant="outlined"
/>
),
},
{
label: 'Prestige',
getValue: p => p.softFactors?.prestige ?? null,
format: (v) => v != null ? String(v) : <em style={{ color: '#94a3b8' }}></em>,
isHigherBetter: true,
},
{
label: 'Erreichbarkeit',
getValue: p => p.softFactors?.accessibility ?? null,
format: (v) => v != null ? String(v) : <em style={{ color: '#94a3b8' }}></em>,
isHigherBetter: true,
},
{
label: 'ÖV-Minuten',
getValue: p => p.softFactors?.publicTransportMinutes ?? null,
format: (v) => v != null ? `${v} Min.` : <em style={{ color: '#94a3b8' }}></em>,
isLowerBetter: true,
},
{
label: 'Fehlende Pflichtfelder',
getValue: p => p.dataQuality.missingCriticalFields.length,
format: (v) => v != null ? String(v) : <em style={{ color: '#94a3b8' }}></em>,
isLowerBetter: true,
},
]
return (
<Box>
{/* Page Header */}
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography variant="h5" fontWeight={700}>Vergleich</Typography>
<Chip label={`${orderedProperties.length} Objekte`} size="small" />
</Box>
<Button variant="outlined" size="small" color="error" onClick={clearCompare}>
Leeren
</Button>
</Box>
<Box sx={{ px: 3, py: 3 }}>
<Card sx={{ overflowX: 'auto' }}>
<Table>
<TableHead>
<TableRow sx={{ bgcolor: '#f8fafc' }}>
<TableCell sx={{ width: 180, fontWeight: 600, color: '#64748b', fontSize: 12 }}>
Kriterium
</TableCell>
{orderedProperties.map(p => (
<TableCell key={p.id} sx={{ borderLeft: '1px solid #f1f5f9', minWidth: 220 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
<Box>
<Typography variant="body2" fontWeight={600}>{p.title}</Typography>
<Chip
label={getResultTypeLabel(p.resultType)}
size="small"
sx={{
bgcolor: getResultTypeColor(p.resultType),
color: 'white',
fontSize: 10,
mt: 0.5,
}}
/>
</Box>
<Button
size="small"
sx={{ minWidth: 'auto', p: 0.5 }}
onClick={() => removeFromCompare(p.id)}
>
<X size={14} />
</Button>
</Box>
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{rows.map(row => {
const values = orderedProperties.map(p => row.getValue(p))
const numericValues = values
.map((v, i) => ({ v, i }))
.filter(x => x.v != null && typeof x.v === 'number') as { v: number; i: number }[]
let bestIdx = -1
if (numericValues.length > 1) {
if (row.isHigherBetter) {
bestIdx = numericValues.reduce((best, cur) => cur.v > best.v ? cur : best).i
} else if (row.isLowerBetter) {
bestIdx = numericValues.reduce((best, cur) => cur.v < best.v ? cur : best).i
}
}
return (
<TableRow key={row.label} hover>
<TableCell sx={{ color: '#64748b', fontSize: 13, fontWeight: 500 }}>
{row.label}
</TableCell>
{orderedProperties.map((p, idx) => {
const raw = row.getValue(p)
const displayValue = row.format
? row.format(raw, p)
: raw != null
? String(raw)
: <em style={{ color: '#94a3b8' }}></em>
const isBest = bestIdx === idx
return (
<NumericCell key={p.id} value={displayValue} isBest={isBest} />
)
})}
</TableRow>
)
})}
</TableBody>
</Table>
</Card>
{/* Add more prompt */}
{orderedProperties.length < 3 && (
<Card sx={{ p: 2.5, mt: 2, border: '2px dashed #e2e8f0', boxShadow: 'none' }}>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Box>
<Typography variant="subtitle2" fontWeight={600}>Weiteres Objekt hinzufügen</Typography>
<Typography variant="caption" color="text.secondary">
Bis zu {3 - orderedProperties.length} weitere{orderedProperties.length < 2 ? 's' : ''} Objekt{orderedProperties.length < 2 ? '' : 'e'} möglich
</Typography>
</Box>
<Button variant="outlined" size="small" onClick={() => navigate('/demand/results')}>
Zur Suche
</Button>
</Stack>
</Card>
)}
</Box>
</Box>
)
}