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:
@@ -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 (60–79)' },
|
||||
{ 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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user