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:
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Box, Card, Chip, FormControlLabel, Slider, Stack, Switch, TextField, Typography } from '@mui/material'
|
import { Box, Card, Chip, Slider, Stack, TextField, Typography } from '@mui/material'
|
||||||
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
|
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
|
||||||
import { AssetType } from '../../domain/enums'
|
import { AssetType } from '../../domain/enums'
|
||||||
import { NeedExtendedRequirements } from './NeedExtendedRequirements'
|
import { NeedExtendedRequirements } from './NeedExtendedRequirements'
|
||||||
@@ -242,31 +242,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Anonymous search */}
|
<NeedExtendedRequirements criteria={c} onChange={set} />
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1, mt: 0.5, py: 1, px: 1.5, borderRadius: 1.5, bgcolor: c.isAnonymous ? 'rgba(124,58,237,0.07)' : '#f8fafc', border: `1px solid ${c.isAnonymous ? '#7c3aed' : '#e8e7e4'}` }}>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: c.isAnonymous ? '#7c3aed' : '#334155' }}>
|
|
||||||
Anonyme Suche
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.65rem' }}>
|
|
||||||
Firmenname wird nicht an Vermieter übermittelt
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
size="small"
|
|
||||||
checked={c.isAnonymous ?? false}
|
|
||||||
onChange={e => set({ ...c, isAnonymous: e.target.checked })}
|
|
||||||
sx={{ '& .MuiSwitch-thumb': { bgcolor: c.isAnonymous ? '#7c3aed' : undefined }, '& .Mui-checked + .MuiSwitch-track': { bgcolor: '#7c3aed' } }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label=""
|
|
||||||
sx={{ m: 0 }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<NeedExtendedRequirements criteria={c} onChange={set} />
|
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ export interface MatchCardViewModel {
|
|||||||
preMarketUnit?: PropertyUnit // specific unit being released pre-market
|
preMarketUnit?: PropertyUnit // specific unit being released pre-market
|
||||||
preMarketAllUnits?: PropertyUnit[] // all units of the backing property
|
preMarketAllUnits?: PropertyUnit[] // all units of the backing property
|
||||||
|
|
||||||
|
// Key metrics for compact display (Ginesta-style metric line)
|
||||||
|
areaSqm?: number
|
||||||
|
rentPerSqm?: number
|
||||||
|
|
||||||
// Score transparency
|
// Score transparency
|
||||||
scoreBreakdown?: {
|
scoreBreakdown?: {
|
||||||
hardMatchScore: number
|
hardMatchScore: number
|
||||||
|
|||||||
@@ -125,16 +125,6 @@ export function UnifiedResultCard({ result, view = 'list' }: Props) {
|
|||||||
const listImageUrl = result.resultType !== 'FUTURE_AVAILABILITY'
|
const listImageUrl = result.resultType !== 'FUTURE_AVAILABILITY'
|
||||||
? result.property.images?.[0]
|
? result.property.images?.[0]
|
||||||
: undefined
|
: undefined
|
||||||
const listOverlay = result.resultType !== 'FUTURE_AVAILABILITY'
|
|
||||||
? resolveImageLabel({
|
|
||||||
assetType: result.property.assetType,
|
|
||||||
city: result.property.location.city,
|
|
||||||
district: result.property.location.district,
|
|
||||||
prestige: result.property.softFactors?.prestige,
|
|
||||||
floorLevel: result.property.floorLevel,
|
|
||||||
propertyId: result.property.id,
|
|
||||||
})
|
|
||||||
: null
|
|
||||||
|
|
||||||
return <MatchCardCompact vm={vm} imageUrl={listImageUrl} overlayTypeLabel={listOverlay?.type} />
|
return <MatchCardCompact vm={vm} imageUrl={listImageUrl} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export function UnifiedResultFeed({ results, view = 'list' }: Props) {
|
|||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
{items.map(result => (
|
{items.map(result => (
|
||||||
<UnifiedResultCard key={result.matchId} result={result} view="list" />
|
<UnifiedResultCard key={result.matchId} result={result} view="list" />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -43,19 +43,26 @@ export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({
|
|||||||
<Box
|
<Box
|
||||||
onClick={() => onSelect(p.id)}
|
onClick={() => onSelect(p.id)}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 1,
|
borderRadius: 2,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
border: `1px solid ${DS_BORDER.default}`,
|
border: `1px solid ${DS_BORDER.default}`,
|
||||||
bgcolor: '#ffffff',
|
bgcolor: '#ffffff',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'border-color 0.15s',
|
position: 'relative',
|
||||||
'&:hover': { borderColor: '#c8c7c4' },
|
zIndex: 0,
|
||||||
|
transition: 'transform 0.18s ease, box-shadow 0.18s ease, border-color 0.15s',
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: '#b0aead',
|
||||||
|
transform: 'scale(1.025)',
|
||||||
|
boxShadow: '0 10px 32px rgba(0,0,0,0.13)',
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* ── Image — clean, no text overlay ── */}
|
{/* ── Image — clean, no text overlay ── */}
|
||||||
<Box sx={{ position: 'relative', height: 150, flexShrink: 0, bgcolor: '#f4f3f0', overflow: 'hidden' }}>
|
<Box sx={{ position: 'relative', aspectRatio: '3/2', flexShrink: 0, bgcolor: '#f4f3f0', overflow: 'hidden' }}>
|
||||||
{hasImage ? (
|
{hasImage ? (
|
||||||
<Box component="img" src={p.images![0]} alt={p.title}
|
<Box component="img" src={p.images![0]} alt={p.title}
|
||||||
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||||
@@ -78,7 +85,7 @@ export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* ── Body ── */}
|
{/* ── Body ── */}
|
||||||
<Box sx={{ px: 1.75, pt: 1.5, pb: 1.75, display: 'flex', flexDirection: 'column', gap: 1.125, flex: 1 }}>
|
<Box sx={{ px: 2.25, pt: 1.75, pb: 2, display: 'flex', flexDirection: 'column', gap: 1.5, flex: 1 }}>
|
||||||
|
|
||||||
{/* Title + location + status inline */}
|
{/* Title + location + status inline */}
|
||||||
<Box>
|
<Box>
|
||||||
@@ -130,36 +137,32 @@ export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Property spec chips */}
|
{/* Chips + Datenvollständigkeit in einer Zeile */}
|
||||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, mt: 'auto' }}>
|
||||||
{p.hardFacts?.fitOut && (() => {
|
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
const meta = FIT_OUT_META[p.hardFacts!.fitOut!] ?? { label: p.hardFacts!.fitOut!, tip: '' }
|
{p.hardFacts?.fitOut && (() => {
|
||||||
return (
|
const meta = FIT_OUT_META[p.hardFacts!.fitOut!] ?? { label: p.hardFacts!.fitOut!, tip: '' }
|
||||||
<Tooltip title={meta.tip} placement="top" arrow>
|
return (
|
||||||
<Chip label={meta.label} size="small"
|
<Tooltip title={meta.tip} placement="top" arrow>
|
||||||
sx={{ height: 20, fontSize: '0.68rem', bgcolor: '#f4f3f0', color: DS_TEXT.secondary, border: `1px solid ${DS_BORDER.default}`, cursor: 'help' }} />
|
<Chip label={meta.label} size="small"
|
||||||
</Tooltip>
|
sx={{ height: 20, fontSize: '0.68rem', bgcolor: '#f4f3f0', color: DS_TEXT.secondary, border: `1px solid ${DS_BORDER.default}`, cursor: 'help' }} />
|
||||||
)
|
</Tooltip>
|
||||||
})()}
|
)
|
||||||
{p.contractDurationMonths && (
|
})()}
|
||||||
<Chip label={`${p.contractDurationMonths}M Vertrag`} size="small"
|
{p.contractDurationMonths && (
|
||||||
sx={{ height: 20, fontSize: '0.68rem', bgcolor: '#f4f3f0', color: DS_TEXT.secondary, border: `1px solid ${DS_BORDER.default}` }} />
|
<Chip label={`${p.contractDurationMonths}M Vertrag`} size="small"
|
||||||
)}
|
sx={{ height: 20, fontSize: '0.68rem', bgcolor: '#f4f3f0', color: DS_TEXT.secondary, border: `1px solid ${DS_BORDER.default}` }} />
|
||||||
</Box>
|
)}
|
||||||
|
</Box>
|
||||||
{/* Data completeness */}
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexShrink: 0 }}>
|
||||||
<Box sx={{ mt: 'auto' }}>
|
<Typography sx={{ fontSize: '0.62rem', color: DS_TEXT.disabled }}>Daten</Typography>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
|
<LinearProgress variant="determinate" value={confPct}
|
||||||
<Typography sx={{ fontSize: '0.62rem', color: DS_TEXT.disabled, letterSpacing: '0.03em' }}>
|
sx={{ width: 40, height: 3, borderRadius: 1, bgcolor: '#f4f3f0',
|
||||||
Datenvollständigkeit
|
'& .MuiLinearProgress-bar': { bgcolor: '#152642', borderRadius: 1 } }} />
|
||||||
</Typography>
|
<Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: p.confidenceScore >= 0.75 ? '#b8975a' : DS_TEXT.muted }}>
|
||||||
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: p.confidenceScore >= 0.75 ? '#b8975a' : DS_TEXT.muted }}>
|
|
||||||
{confPct}%
|
{confPct}%
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<LinearProgress variant="determinate" value={confPct}
|
|
||||||
sx={{ height: 3, borderRadius: 1, bgcolor: '#f4f3f0',
|
|
||||||
'& .MuiLinearProgress-bar': { bgcolor: '#152642', borderRadius: 1 } }} />
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
+22
-102
@@ -1,11 +1,10 @@
|
|||||||
import { useState, useMemo, useEffect } from 'react'
|
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 { Zap } from 'lucide-react'
|
||||||
import { useNavigate, useLocation } from 'react-router'
|
import { useNavigate, useLocation } from 'react-router'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { useUnifiedResults } from '../../hooks/useUnifiedResults'
|
import { useUnifiedResults } from '../../hooks/useUnifiedResults'
|
||||||
import { useNeeds } from '../../hooks/useNeeds'
|
import { useNeeds } from '../../hooks/useNeeds'
|
||||||
import { DecisionContextPanel } from '../../components/ui'
|
|
||||||
import {
|
import {
|
||||||
FeedEmptyState,
|
FeedEmptyState,
|
||||||
FeedSkeleton,
|
FeedSkeleton,
|
||||||
@@ -72,16 +71,14 @@ export default function Results() {
|
|||||||
const isStaff = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
const isStaff = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
||||||
|
|
||||||
// Counts over the full result set — single pass, stable across filter changes
|
// Counts over the full result set — single pass, stable across filter changes
|
||||||
const { platformCount, maisonWorkCount, futureCount, missingDataCount } = useMemo(() => {
|
const { platformCount, maisonWorkCount, futureCount } = useMemo(() => {
|
||||||
let platform = 0, maison = 0, future = 0, missing = 0
|
let platform = 0, maison = 0, future = 0
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
if (r.resultType === 'VERIFIED_PORTFOLIO') platform++
|
if (r.resultType === 'VERIFIED_PORTFOLIO') platform++
|
||||||
else if (r.resultType === 'MAISON_WORK') maison++
|
else if (r.resultType === 'MAISON_WORK') maison++
|
||||||
else if (r.resultType === 'FUTURE_AVAILABILITY') future++
|
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])
|
}, [results])
|
||||||
|
|
||||||
// Filter + sort in one memo — only reruns when inputs change
|
// Filter + sort in one memo — only reruns when inputs change
|
||||||
@@ -98,7 +95,6 @@ export default function Results() {
|
|||||||
return sortResults(filtered, sortBy)
|
return sortResults(filtered, sortBy)
|
||||||
}, [results, filterSource, sortBy, showFutureAvailability, showOwnProperties])
|
}, [results, filterSource, sortBy, showFutureAvailability, showOwnProperties])
|
||||||
|
|
||||||
const strongCount = useMemo(() => sorted.filter(r => r.matchScore >= 80).length, [sorted])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
<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) }}
|
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 }}>
|
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3, pb: 10 }}>
|
||||||
|
|
||||||
|
{/* Aktive Suche */}
|
||||||
{activeNeed && (
|
{activeNeed && (
|
||||||
<Card sx={{ bgcolor: '#eff6ff', p: 2, mb: 2, border: '1px solid #bfdbfe' }}>
|
<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', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2 }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: '4px 16px', alignItems: 'center' }}>
|
||||||
<Box>
|
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642' }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }} color="#152642">
|
{activeNeed.companyName}
|
||||||
Aktive Suche: {activeNeed.companyName}
|
</Typography>
|
||||||
</Typography>
|
<Typography variant="caption" color="text.secondary">{activeNeed.assetType}</Typography>
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: '4px 16px' }}>
|
<Typography variant="caption" color="text.secondary">{activeNeed.requiredArea.min}–{activeNeed.requiredArea.max} m²</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">{activeNeed.preferredLocations.join(', ')}</Typography>
|
||||||
<strong>Typ:</strong> {activeNeed.assetType}
|
{activeNeed.budgetRange?.maxPerSqm > 0 && (
|
||||||
</Typography>
|
<Typography variant="caption" color="text.secondary">max. CHF {activeNeed.budgetRange.maxPerSqm}/m²/J</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>
|
</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
|
<ResultFilterBar
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ export default function Properties() {
|
|||||||
const [view, setView] = useState<'list' | 'grid'>(() =>
|
const [view, setView] = useState<'list' | 'grid'>(() =>
|
||||||
(localStorage.getItem('view-properties') as 'list' | 'grid') ?? 'list'
|
(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(() =>
|
const [headerOpen, setHeaderOpen] = useState(() =>
|
||||||
localStorage.getItem('props-header-open') !== 'false'
|
localStorage.getItem('props-header-open') !== 'false'
|
||||||
)
|
)
|
||||||
@@ -109,10 +112,31 @@ export default function Properties() {
|
|||||||
title="Objektverwaltung"
|
title="Objektverwaltung"
|
||||||
subtitle={`${filtered.length} von ${properties.length} Objekten`}
|
subtitle={`${filtered.length} von ${properties.length} Objekten`}
|
||||||
secondaryActions={
|
secondaryActions={
|
||||||
<ViewToggle
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||||
view={view}
|
{view === 'grid' && (
|
||||||
onChange={v => { setView(v); localStorage.setItem('view-properties', v) }}
|
<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' }}>
|
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||||||
{view === 'grid' ? (
|
{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 => (
|
{filtered.map(p => (
|
||||||
<PropertyIntelligenceCard key={p.id} property={p} onSelect={setSelectedId} inquiryCount={inquiryCountByProperty.get(p.id) ?? 0} />
|
<PropertyIntelligenceCard key={p.id} property={p} onSelect={setSelectedId} inquiryCount={inquiryCountByProperty.get(p.id) ?? 0} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user