feat: role-aware UX, market intelligence, score fix & layout scroll

- Role-based workspace access: Property Manager gets Supply+Demand,
  Operations restricted to Super Admin + Reviewer only
- Root redirect routes each persona to their first workspace
- Match Center: replaced 3-panel with auto-sorted flat list + drawer
- AI Search: unified form with dual-action (Jetzt suchen / Als Suchprofil speichern)
- CompareTray hidden on non-Demand routes; Vergleichen removed from Supply
- LocationIntelligencePanel: city KPIs, rent trends, soft factors, comparables
- NegotiationInsightsPanel: price positioning, active demand, selling arguments
- scoreCalculator: cap hardMatchScore and softFactorScore to max 100
- Layout: add display:flex to overflow:hidden wrappers so inner scroll works
  (MatchCenter drawer, MarketIntelligence, SourceMonitoring, SignalPipeline)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-17 00:58:10 +02:00
parent e13fe470fb
commit efc406ace9
37 changed files with 4871 additions and 459 deletions
+179 -59
View File
@@ -1,73 +1,193 @@
import { Box, Paper, Typography } from '@mui/material'
import { useMatches } from '../../hooks/useMatches'
import { useMemo, useState } from 'react'
import {
PropertySelectionPanel,
NeedSelectionPanel,
MatchBriefingPanel,
} from '../../components/match-center'
Box,
Chip,
Drawer,
IconButton,
MenuItem,
Select,
Typography,
} from '@mui/material'
import { X } from 'lucide-react'
import { useMatches, useApproveMatch } from '../../hooks/useMatches'
import { useProperties } from '../../hooks/useProperties'
import { useNeeds } from '../../hooks/useNeeds'
import { useMatchCenterStore } from '../../stores/matchCenterStore'
import { MatchListCard, MatchBriefingPanel, MatchCenterSkeleton } from '../../components/match-center'
import type { Match } from '../../domain/match'
const PANEL_HEADER_SX = {
px: 2,
py: 1.5,
borderBottom: '1px solid #e2e8f0',
bgcolor: 'white',
position: 'sticky' as const,
top: 0,
zIndex: 1,
flexShrink: 0,
}
const STRENGTH_OPTIONS = [
{ value: '', label: 'Alle Stärken' },
{ value: 'STRONG', label: 'Stark (≥80)' },
{ value: 'MODERATE', label: 'Mittel (6079)' },
{ value: 'WEAK', label: 'Schwach (<60)' },
]
const STATUS_OPTIONS = [
{ value: '', label: 'Alle Status' },
{ value: 'PENDING_REVIEW', label: 'Ausstehend' },
{ value: 'APPROVED', label: 'Genehmigt' },
{ value: 'REJECTED', label: 'Abgelehnt' },
]
export default function MatchCenter() {
const { data: matches = [] } = useMatches()
const { data: matches = [], isLoading } = useMatches()
const { data: properties = [] } = useProperties()
const { data: needs = [] } = useNeeds()
const { setSelectedProperty, setSelectedNeed } = useMatchCenterStore()
const approveMatch = useApproveMatch()
const [selectedMatchId, setSelectedMatchId] = useState<string | null>(null)
const [filterStrength, setFilterStrength] = useState('')
const [filterStatus, setFilterStatus] = useState('')
const propMap = useMemo(() => new Map(properties.map(p => [p.id, p])), [properties])
const needMap = useMemo(() => new Map(needs.map(n => [n.id, n])), [needs])
const filtered = useMemo(() => {
return matches
.filter(m => {
if (filterStrength && m.matchStrength !== filterStrength) return false
if (filterStatus && m.status !== filterStatus) return false
return true
})
.sort((a, b) => b.matchScore - a.matchScore)
}, [matches, filterStrength, filterStatus])
const strongCount = matches.filter(m => m.matchScore >= 80).length
const pendingCount = matches.filter(m => m.status === 'PENDING_REVIEW').length
function handleSelectMatch(match: Match) {
setSelectedMatchId(match.id)
setSelectedProperty(match.propertyId)
setSelectedNeed(match.needId)
}
function handleCloseDrawer() {
setSelectedMatchId(null)
setSelectedProperty(null)
setSelectedNeed(null)
}
return (
<Box sx={{ display: 'flex', height: 'calc(100vh - 64px)', overflow: 'hidden' }}>
{/* Left: Properties */}
<Paper elevation={0} sx={{
width: 260,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
borderRadius: 0,
borderRight: '1px solid #e2e8f0',
overflow: 'hidden',
}}>
<Box sx={PANEL_HEADER_SX}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Objekte</Typography>
<Typography variant="caption" color="text.secondary">{matches.length} Matches gesamt</Typography>
</Box>
<PropertySelectionPanel matches={matches} />
</Paper>
<Box sx={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 64px)', overflow: 'hidden' }}>
{/* Center: Match Briefing */}
<Box sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
bgcolor: '#f8fafc',
}}>
<Box sx={PANEL_HEADER_SX}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Match-Briefing</Typography>
{/* Header */}
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, flexShrink: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 2, mb: 1 }}>
<Typography variant="h5" sx={{ fontWeight: 700 }}>Match Center</Typography>
<Typography variant="body2" color="text.secondary">Automatisch berechnete Matches</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip label={`${matches.length} Matches`} size="small" sx={{ bgcolor: '#f1f5f9', color: '#475569' }} />
<Chip
label={`${strongCount} Stark`}
size="small"
sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', fontWeight: 600 }}
/>
{pendingCount > 0 && (
<Chip
label={`${pendingCount} Ausstehend`}
size="small"
sx={{ bgcolor: '#fef3c7', color: '#d97706', fontWeight: 600 }}
/>
)}
</Box>
<MatchBriefingPanel />
</Box>
{/* Right: Needs */}
<Paper elevation={0} sx={{
width: 260,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
borderRadius: 0,
borderLeft: '1px solid #e2e8f0',
overflow: 'hidden',
}}>
<Box sx={PANEL_HEADER_SX}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Bedarfe</Typography>
{/* Filter bar */}
<Box
sx={{
bgcolor: 'white',
borderBottom: '1px solid #e2e8f0',
px: 3,
py: 1,
display: 'flex',
gap: 1.5,
alignItems: 'center',
flexShrink: 0,
}}
>
<Select
size="small"
value={filterStrength}
onChange={e => setFilterStrength(e.target.value)}
displayEmpty
sx={{ fontSize: '0.8125rem', minWidth: 160 }}
>
{STRENGTH_OPTIONS.map(o => (
<MenuItem key={o.value} value={o.value} sx={{ fontSize: '0.8125rem' }}>{o.label}</MenuItem>
))}
</Select>
<Select
size="small"
value={filterStatus}
onChange={e => setFilterStatus(e.target.value)}
displayEmpty
sx={{ fontSize: '0.8125rem', minWidth: 160 }}
>
{STATUS_OPTIONS.map(o => (
<MenuItem key={o.value} value={o.value} sx={{ fontSize: '0.8125rem' }}>{o.label}</MenuItem>
))}
</Select>
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>
{filtered.length} von {matches.length} Matches
</Typography>
</Box>
{/* Match list */}
<Box sx={{ flex: 1, overflowY: 'auto', bgcolor: '#f8fafc' }}>
{isLoading ? (
<MatchCenterSkeleton />
) : filtered.length === 0 ? (
<Box sx={{ p: 6, textAlign: 'center' }}>
<Typography color="text.secondary">Keine Matches für diese Filter.</Typography>
</Box>
) : (
<Box sx={{ bgcolor: 'white' }}>
{filtered.map(match => (
<MatchListCard
key={match.id}
match={match}
property={propMap.get(match.propertyId)}
need={needMap.get(match.needId)}
onSelect={() => handleSelectMatch(match)}
onApprove={() => approveMatch.mutate(match.id)}
/>
))}
</Box>
)}
</Box>
{/* Detail Drawer */}
<Drawer
anchor="right"
open={!!selectedMatchId}
onClose={handleCloseDrawer}
slotProps={{ paper: { sx: { width: 650, boxShadow: '-4px 0 24px rgba(0,0,0,0.10)' } } }}
>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2.5,
py: 1.5,
borderBottom: '1px solid #e2e8f0',
flexShrink: 0,
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Match-Briefing</Typography>
<IconButton size="small" onClick={handleCloseDrawer} sx={{ color: '#94a3b8' }}>
<X size={16} />
</IconButton>
</Box>
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<MatchBriefingPanel />
</Box>
</Box>
<NeedSelectionPanel matches={matches} />
</Paper>
</Drawer>
</Box>
)
}