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
@@ -0,0 +1,131 @@
import { Box, Button, Chip, Typography } from '@mui/material'
import { MatchStatusBadge } from './MatchStatusBadge'
import type { Match } from '../../domain/match'
import type { Property } from '../../domain/property'
import type { Need } from '../../domain/need'
const STRENGTH_META: Record<string, { label: string; bg: string; color: string }> = {
STRONG: { label: 'Stark', bg: '#f0fdf4', color: '#1a7a4a' },
MODERATE: { label: 'Mittel', bg: '#fefce8', color: '#d97706' },
WEAK: { label: 'Schwach', bg: '#fff1f2', color: '#c0392b' },
}
function scoreColor(score: number) {
if (score >= 80) return '#1a7a4a'
if (score >= 60) return '#d97706'
return '#c0392b'
}
interface Props {
match: Match
property: Property | undefined
need: Need | undefined
onSelect: () => void
onApprove: () => void
}
export function MatchListCard({ match, property, need, onSelect, onApprove }: Props) {
const strength = STRENGTH_META[match.matchStrength] ?? { label: match.matchStrength, bg: '#f1f5f9', color: '#64748b' }
const summary = match.explainabilitySummary ?? ''
return (
<Box
onClick={onSelect}
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
px: 3,
py: 2,
borderBottom: '1px solid #e2e8f0',
bgcolor: 'white',
cursor: 'pointer',
'&:hover': { bgcolor: '#f8fafc' },
}}
>
{/* Score bubble */}
<Box
sx={{
width: 52,
height: 52,
borderRadius: 2,
bgcolor: scoreColor(match.matchScore),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Typography variant="h6" sx={{ color: 'white', fontWeight: 700, lineHeight: 1 }}>
{match.matchScore}
</Typography>
</Box>
{/* Property + need */}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.25, flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ fontWeight: 600, whiteSpace: 'nowrap' }}>
{property?.title ?? match.propertyId}
</Typography>
<MatchStatusBadge status={match.status} />
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{[property?.location.city, property?.areaSqm ? `${property.areaSqm}` : null].filter(Boolean).join(' · ')}
</Typography>
<Box sx={{ display: 'flex', gap: 0.75, mt: 0.5, flexWrap: 'wrap' }}>
{need && (
<Chip
label={need.companyName}
size="small"
sx={{ bgcolor: '#eff6ff', color: '#1e3a5f', fontSize: 11, height: 20, fontWeight: 500 }}
/>
)}
<Chip
label={strength.label}
size="small"
sx={{ bgcolor: strength.bg, color: strength.color, fontSize: 11, height: 20 }}
/>
</Box>
</Box>
{/* Summary excerpt */}
{summary && (
<Typography
variant="caption"
color="text.secondary"
sx={{
maxWidth: 220,
display: { xs: 'none', lg: 'block' },
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{summary.length > 90 ? summary.slice(0, 90) + '…' : summary}
</Typography>
)}
{/* Actions */}
<Box sx={{ display: 'flex', gap: 0.75, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
{match.status !== 'APPROVED' && (
<Button
size="small"
variant="contained"
onClick={onApprove}
sx={{ textTransform: 'none', bgcolor: '#1a7a4a', '&:hover': { bgcolor: '#15643c' } }}
>
Genehmigen
</Button>
)}
<Button
size="small"
variant="outlined"
onClick={onSelect}
sx={{ textTransform: 'none' }}
>
Details
</Button>
</Box>
</Box>
)
}