refactor: premium design polish — cards, results, grid view

- PropertyIntelligenceCard: aspect-ratio image, hover animation, compact
  data-completeness row, tighter body padding
- Results: replace verbose Card with compact active-search strip, remove
  DecisionContextPanel
- Properties: add 3/5/10 column toggle in grid view, persist to localStorage
- NeedInput, UnifiedResultCard/Feed, MatchCardViewModel: cleanup from
  earlier session

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-06-11 00:06:23 +02:00
parent 6e67c698ce
commit beecf754d5
7 changed files with 94 additions and 177 deletions
+22 -102
View File
@@ -1,11 +1,10 @@
import { useState, useMemo, useEffect } from 'react'
import { Box, Button, Card, Chip, Typography } from '@mui/material'
import { Box, Button, Typography } from '@mui/material'
import { Zap } from 'lucide-react'
import { useNavigate, useLocation } from 'react-router'
import { useQueryClient } from '@tanstack/react-query'
import { useUnifiedResults } from '../../hooks/useUnifiedResults'
import { useNeeds } from '../../hooks/useNeeds'
import { DecisionContextPanel } from '../../components/ui'
import {
FeedEmptyState,
FeedSkeleton,
@@ -72,16 +71,14 @@ export default function Results() {
const isStaff = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
// Counts over the full result set — single pass, stable across filter changes
const { platformCount, maisonWorkCount, futureCount, missingDataCount } = useMemo(() => {
let platform = 0, maison = 0, future = 0, missing = 0
const { platformCount, maisonWorkCount, futureCount } = useMemo(() => {
let platform = 0, maison = 0, future = 0
for (const r of results) {
if (r.resultType === 'VERIFIED_PORTFOLIO') platform++
else if (r.resultType === 'MAISON_WORK') maison++
else if (r.resultType === 'FUTURE_AVAILABILITY') future++
if ('match' in r && Array.isArray((r as { match?: { missingData?: unknown[] } }).match?.missingData) &&
((r as { match?: { missingData?: unknown[] } }).match?.missingData?.length ?? 0) > 0) missing++
}
return { platformCount: platform, maisonWorkCount: maison, futureCount: future, missingDataCount: missing }
return { platformCount: platform, maisonWorkCount: maison, futureCount: future }
}, [results])
// Filter + sort in one memo — only reruns when inputs change
@@ -98,7 +95,6 @@ export default function Results() {
return sortResults(filtered, sortBy)
}, [results, filterSource, sortBy, showFutureAvailability, showOwnProperties])
const strongCount = useMemo(() => sorted.filter(r => r.matchScore >= 80).length, [sorted])
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
@@ -113,103 +109,27 @@ export default function Results() {
onViewChange={v => { setView(v); localStorage.setItem('view-results', v) }}
/>
{!isLoading && results.length > 0 && (
<DecisionContextPanel
decision="Welche Treffer lohnen sich für die Shortlist — und welche tragen Risiken, die zuerst geprüft werden müssen?"
context={activeNeed ? `Suche: ${activeNeed.assetType} · ${activeNeed.requiredArea.min}${activeNeed.requiredArea.max} m² · ${activeNeed.preferredLocations.join(', ')}` : undefined}
metrics={[
...(strongCount > 0 ? [{ label: 'starke Treffer (≥80)', value: strongCount, severity: 'positive' as const }] : []),
...(maisonWorkCount > 0 ? [{ label: 'Maison Work', value: maisonWorkCount, severity: 'neutral' as const }] : []),
...(futureCount > 0 ? [{ label: 'Zukunftssignale', value: futureCount, severity: 'neutral' as const }] : []),
...(missingDataCount > 0 ? [{ label: 'mit Datenlücken', value: missingDataCount, severity: 'warning' as const }] : []),
]}
risks={[
...(missingDataCount > 0 ? [`${missingDataCount} Treffer mit fehlenden Daten — Einschätzung eingeschränkt`] : []),
]}
actions={[
{ label: 'Vergleich öffnen', onClick: () => navigate('/demand/compare') },
{ label: 'Suche anpassen', onClick: () => navigate('/demand/ai-search') },
]}
/>
)}
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3, pb: 10 }}>
{/* Aktive Suche */}
{activeNeed && (
<Card sx={{ bgcolor: '#eff6ff', p: 2, mb: 2, border: '1px solid #bfdbfe' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2 }}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }} color="#152642">
Aktive Suche: {activeNeed.companyName}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: '4px 16px' }}>
<Typography variant="caption" color="text.secondary">
<strong>Typ:</strong> {activeNeed.assetType}
</Typography>
<Typography variant="caption" color="text.secondary">
<strong>Fläche:</strong> {activeNeed.requiredArea.min}{activeNeed.requiredArea.max} m²
</Typography>
<Typography variant="caption" color="text.secondary">
<strong>Standort:</strong> {activeNeed.preferredLocations.join(', ')}
</Typography>
{activeNeed.budgetRange && activeNeed.budgetRange.maxPerSqm > 0 && (
<Typography variant="caption" color="text.secondary">
<strong>Budget:</strong> max. CHF {activeNeed.budgetRange.maxPerSqm}/m²/Jahr
</Typography>
)}
{activeNeed.timing?.earliestMoveIn && !isNaN(new Date(activeNeed.timing.earliestMoveIn).getTime()) && (
<Typography variant="caption" color="text.secondary">
<strong>Bezug ab:</strong> {new Date(activeNeed.timing.earliestMoveIn).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' })}
</Typography>
)}
{activeNeed.searchRadius && (
<Typography variant="caption" color="text.secondary">
<strong>Radius:</strong> {activeNeed.searchRadius} km
</Typography>
)}
{activeNeed.requiresDivisibility && activeNeed.minDivisibleUnit && (
<Typography variant="caption" color="text.secondary">
<strong>Teilbar ab:</strong> {activeNeed.minDivisibleUnit} m²
</Typography>
)}
{activeNeed.requiredFitOut && (
<Typography variant="caption" color="text.secondary">
<strong>Ausbaustandard:</strong> min. {
activeNeed.requiredFitOut === 'BASIC' ? 'Basisausbau' :
activeNeed.requiredFitOut === 'FULL' ? 'Vollausbau' : 'Premiumausbau'
}
</Typography>
)}
{activeNeed.fitOutBudgetMaxPerSqm && (
<Typography variant="caption" color="text.secondary">
<strong>Ausbaubudget:</strong> max. CHF {activeNeed.fitOutBudgetMaxPerSqm}/m²
</Typography>
)}
{activeNeed.mustCriteriaText && activeNeed.mustCriteriaText.length > 0 && (
<Typography variant="caption" color="text.secondary">
<strong>Must-haves:</strong> {activeNeed.mustCriteriaText.slice(0, 3).join(' · ')}
</Typography>
)}
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
{activeNeed.isAnonymous && (
<Chip
label="Anonyme Suche"
size="small"
sx={{ bgcolor: '#7c3aed', color: 'white', fontWeight: 700, fontSize: 10, height: 20 }}
/>
)}
<Button
size="small"
variant="text"
onClick={() => navigate('/demand/ai-search')}
sx={{ color: '#152642', flexShrink: 0 }}
>
Suche ändern
</Button>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2, bgcolor: '#eff6ff', border: '1px solid #bfdbfe', borderRadius: 1.5, px: 2, py: 1.25, mb: 2 }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: '4px 16px', alignItems: 'center' }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642' }}>
{activeNeed.companyName}
</Typography>
<Typography variant="caption" color="text.secondary">{activeNeed.assetType}</Typography>
<Typography variant="caption" color="text.secondary">{activeNeed.requiredArea.min}{activeNeed.requiredArea.max} m²</Typography>
<Typography variant="caption" color="text.secondary">{activeNeed.preferredLocations.join(', ')}</Typography>
{activeNeed.budgetRange?.maxPerSqm > 0 && (
<Typography variant="caption" color="text.secondary">max. CHF {activeNeed.budgetRange.maxPerSqm}/m²/J</Typography>
)}
</Box>
</Card>
<Button size="small" variant="text" onClick={() => navigate('/demand/ai-search')}
sx={{ color: '#152642', flexShrink: 0, textTransform: 'none', fontSize: '0.75rem' }}>
Suche ändern
</Button>
</Box>
)}
<ResultFilterBar
+29 -5
View File
@@ -57,6 +57,9 @@ export default function Properties() {
const [view, setView] = useState<'list' | 'grid'>(() =>
(localStorage.getItem('view-properties') as 'list' | 'grid') ?? 'list'
)
const [columns, setColumns] = useState<3 | 5 | 10>(() =>
(Number(localStorage.getItem('view-properties-cols')) as 3 | 5 | 10) || 3
)
const [headerOpen, setHeaderOpen] = useState(() =>
localStorage.getItem('props-header-open') !== 'false'
)
@@ -109,10 +112,31 @@ export default function Properties() {
title="Objektverwaltung"
subtitle={`${filtered.length} von ${properties.length} Objekten`}
secondaryActions={
<ViewToggle
view={view}
onChange={v => { setView(v); localStorage.setItem('view-properties', v) }}
/>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
{view === 'grid' && (
<Box sx={{ display: 'flex', border: '1px solid #e2e8f0', borderRadius: 1, overflow: 'hidden' }}>
{([3, 5, 10] as const).map(n => (
<Box
key={n}
onClick={() => { setColumns(n); localStorage.setItem('view-properties-cols', String(n)) }}
sx={{
width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: '0.72rem', fontWeight: 600,
bgcolor: columns === n ? '#152642' : 'transparent',
color: columns === n ? 'white' : '#64748b',
'&:hover': { bgcolor: columns === n ? '#162d4a' : '#f1f5f9' },
}}
>
{n}
</Box>
))}
</Box>
)}
<ViewToggle
view={view}
onChange={v => { setView(v); localStorage.setItem('view-properties', v) }}
/>
</Box>
}
/>
@@ -201,7 +225,7 @@ export default function Properties() {
<Box sx={{ flex: 1, overflowY: 'auto' }}>
{view === 'grid' ? (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 2, p: 2.5 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: 3, p: 3 }}>
{filtered.map(p => (
<PropertyIntelligenceCard key={p.id} property={p} onSelect={setSelectedId} inquiryCount={inquiryCountByProperty.get(p.id) ?? 0} />
))}