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
+3 -3
View File
@@ -18,10 +18,10 @@ import { UserRole } from '../../domain/enums'
const DEMO_ROLES: { role: UserRole; label: string; description: string }[] = [
{ role: UserRole.ORGANIZATION_ADMIN, label: 'Org Admin', description: 'Vollzugriff Supply + Demand + Ops' },
{ role: UserRole.PROPERTY_MANAGER, label: 'Property Manager', description: 'Supply Workspace' },
{ role: UserRole.DEMAND_USER, label: 'Demand User', description: 'Demand Workspace' },
{ role: UserRole.PROPERTY_MANAGER, label: 'Verwaltung', description: 'Portfolio verwalten + Markt durchsuchen' },
{ role: UserRole.DEMAND_USER, label: 'Bürosuche', description: 'Nur Marktsuche — kein Portfolio' },
{ role: UserRole.REVIEWER, label: 'Reviewer', description: 'Operations Workspace' },
{ role: UserRole.OWNER_VIEWER, label: 'Owner Viewer', description: 'Supply (eingeschränkt)' },
{ role: UserRole.OWNER_VIEWER, label: 'Eigentümer', description: 'Supply (eingeschränkt)' },
{ role: UserRole.SUPER_ADMIN, label: 'Super Admin', description: 'Plattform-Administrator' },
]
+236 -106
View File
@@ -1,13 +1,19 @@
import { useState } from 'react'
import { Box, Button, CircularProgress, Typography } from '@mui/material'
import { ArrowRight, ArrowLeft, Save } from 'lucide-react'
import { useRef, useState } from 'react'
import {
Alert,
Box,
Button,
CircularProgress,
Divider,
Typography,
} from '@mui/material'
import { ArrowRight, Bookmark, Save, Search } from 'lucide-react'
import { useNavigate } from 'react-router'
import { PageHeader } from '../../components/layout'
import { useQueryClient } from '@tanstack/react-query'
import {
NeedBuilderProgress,
NeedInput,
CriteriaReviewPanel,
FollowUpPanel,
VoiceNeedInput,
WeightingEditor,
NeedCardPreview,
NeedBuilderErrorState,
@@ -20,16 +26,34 @@ import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../do
import { AssetType } from '../../domain/enums'
import type { CreateNeedInput } from '../../domain/need'
// ── Map ParsedNeedCriteria → CreateNeedInput ───────────────────────────────────
// ── Helpers ───────────────────────────────────────────────────────────────────
const ASSET_LABELS_TEXT: Record<string, string> = {
OFFICE: 'Bürofläche', RETAIL: 'Retail-Fläche', LOGISTICS: 'Logistikfläche',
PRODUCTION: 'Produktionsfläche', LIGHT_INDUSTRIAL: 'Gewerbefläche', MIXED: 'gemischte Fläche',
}
function generateSummary(c: ParsedNeedCriteria): string {
const parts: string[] = []
if (c.assetType) parts.push(`Suche ${ASSET_LABELS_TEXT[c.assetType] ?? c.assetType}`)
if (c.areaRange && (c.areaRange.min > 0 || c.areaRange.max > 0))
parts.push(`${c.areaRange.min}${c.areaRange.max}`)
if (c.preferredLocations?.length) parts.push(`in ${c.preferredLocations.join(', ')}`)
if (c.budgetRange?.maxPerSqm) parts.push(`Budget max. CHF ${c.budgetRange.maxPerSqm}/m²`)
if (c.timing?.earliestMoveIn) parts.push(`ab ${c.timing.earliestMoveIn}`)
if (c.mustHaveCriteria?.length) parts.push(`Must-haves: ${c.mustHaveCriteria.join(', ')}`)
return parts.join(', ')
}
function buildNeedInput(
criteria: ParsedNeedCriteria,
weights: Record<WeightingKey, number>,
needTitle: string,
overallConfidence: number,
status: 'DRAFT' | 'ACTIVE',
): CreateNeedInput {
return {
companyName: needTitle || criteria.companyName || 'Neuer Bedarf',
companyName: needTitle || criteria.companyName || 'Neue Suche',
assetType: criteria.assetType ?? AssetType.UNKNOWN,
requiredArea: criteria.areaRange ?? { min: 0, max: 0 },
preferredLocations: criteria.preferredLocations ?? [],
@@ -42,77 +66,157 @@ function buildNeedInput(
},
weightingProfile: weights,
confidenceInCriteria: overallConfidence,
status: overallConfidence < 0.6 ? 'DRAFT' : 'ACTIVE',
status,
mustCriteriaText: criteria.mustHaveCriteria ?? [],
notes: criteria.notes,
extractedFromText: undefined,
}
}
// ── Action intent ─────────────────────────────────────────────────────────────
type ActionIntent = 'search' | 'save-profile'
// ── Page ──────────────────────────────────────────────────────────────────────
export default function AISearch() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const [step, setStep] = useState<typeof NeedBuilderStep[keyof typeof NeedBuilderStep]>(NeedBuilderStep.IDLE)
const [intent, setIntent] = useState<ActionIntent>('search')
const [inputText, setInputText] = useState('')
const [isAutoGen, setIsAutoGen] = useState(false)
const [criteria, setCriteria] = useState<ParsedNeedCriteria>({})
const [parseResult, setParseResult] = useState<ParseNeedResult | null>(null)
const [editedCriteria, setEditedCriteria] = useState<ParsedNeedCriteria | null>(null)
const [answers, setAnswers] = useState<Record<string, string>>({})
const [weights, setWeights] = useState<Record<WeightingKey, number>>(weightingService.getDefaultWeights())
const [weightingKey, setWeightingKey] = useState(0)
const [needTitle, setNeedTitle] = useState('')
const [error, setError] = useState<string | null>(null)
async function handleAnalyze() {
const isManualTextRef = useRef(false)
function handleCriteriaChange(next: ParsedNeedCriteria) {
setCriteria(next)
if (!isManualTextRef.current) {
const summary = generateSummary(next)
setInputText(summary)
setIsAutoGen(!!summary)
}
}
function handleTextChange(text: string) {
isManualTextRef.current = text !== ''
setIsAutoGen(false)
setInputText(text)
}
async function handleAiAutofill() {
setStep(NeedBuilderStep.PARSING)
setError(null)
try {
const resp = await aiService.parseNeed(inputText)
const result = resp.data
setParseResult(result)
setEditedCriteria({ ...result.extractedCriteria })
setCriteria({ ...result.extractedCriteria })
setWeights(result.suggestedWeights as Record<WeightingKey, number>)
setStep(NeedBuilderStep.PARSED_REQUIRES_REVIEW)
setWeightingKey(k => k + 1)
isManualTextRef.current = false
setInputText('')
setIsAutoGen(false)
setStep(NeedBuilderStep.IDLE)
} catch {
setError('Die KI-Analyse ist fehlgeschlagen. Bitte versuchen Sie es erneut.')
setError('Die KI-Analyse ist fehlgeschlagen.')
setStep(NeedBuilderStep.ERROR)
}
}
async function handleReparse() {
if (!editedCriteria) return
setStep(NeedBuilderStep.PARSING)
try {
const resp = await aiService.generateFollowUpQuestions(editedCriteria)
if (parseResult) {
setParseResult({ ...parseResult, followUpQuestionCandidates: resp.data })
// Resolve criteria (parse text if needed), then either search or show save preview
async function handleAction(chosenIntent: ActionIntent) {
setIntent(chosenIntent)
setError(null)
let resolved: ParsedNeedCriteria = criteria
let resolvedResult: ParseNeedResult | null = parseResult
if (!hasStructuredData && inputText.trim()) {
setStep(NeedBuilderStep.PARSING)
try {
const resp = await aiService.parseNeed(inputText)
resolved = resp.data.extractedCriteria
resolvedResult = resp.data
setCriteria(resolved)
setWeights(resp.data.suggestedWeights as Record<WeightingKey, number>)
setWeightingKey(k => k + 1)
isManualTextRef.current = false
setInputText('')
setIsAutoGen(false)
setParseResult(resp.data)
} catch {
setError('Die KI-Analyse ist fehlgeschlagen.')
setStep(NeedBuilderStep.ERROR)
return
}
setStep(NeedBuilderStep.PARSED_REQUIRES_REVIEW)
} catch {
setStep(NeedBuilderStep.PARSED_REQUIRES_REVIEW)
}
if (chosenIntent === 'search') {
// Save as DRAFT and navigate immediately
setStep(NeedBuilderStep.SAVING)
try {
const conf = resolvedResult
? Object.values(resolvedResult.confidenceByField).reduce((s, v) => s + v, 0) /
Math.max(Object.values(resolvedResult.confidenceByField).length, 1)
: 0.5
const input = buildNeedInput(resolved, weights, needTitle, conf, 'DRAFT')
const created = await needService.create(input)
await queryClient.invalidateQueries({ queryKey: ['needs'] })
await queryClient.invalidateQueries({ queryKey: ['matches'] })
navigate('/demand/results', { state: { fromNeedBuilder: true, activeNeedId: created.data.id } })
} catch {
setError('Suche fehlgeschlagen.')
setStep(NeedBuilderStep.ERROR)
}
return
}
// save-profile: show preview step
const confidenceByField: Record<string, number> = resolvedResult?.confidenceByField ?? {}
if (!resolvedResult) {
if (resolved.assetType) confidenceByField.assetType = 1.0
if (resolved.areaRange?.min) confidenceByField.areaRange = 1.0
if (resolved.preferredLocations?.length) confidenceByField.preferredLocations = 1.0
if (resolved.budgetRange?.maxPerSqm) confidenceByField.budgetRange = 1.0
if (resolved.timing?.earliestMoveIn) confidenceByField.timing = 1.0
}
setEditedCriteria({ ...resolved })
setParseResult(resolvedResult ?? {
extractedCriteria: resolved,
confidenceByField,
missingFields: [],
assumptions: [],
suggestedWeights: weights,
followUpQuestionCandidates: [],
rawSummary: 'Manuell eingegeben',
promptVersion: 'manual',
schemaVersion: '1.0',
})
setStep(NeedBuilderStep.READY_TO_SAVE)
}
function handleAnswer(id: string, ans: string) {
setAnswers(prev => ({ ...prev, [id]: ans }))
}
async function handleSave() {
async function handleSaveProfile() {
if (!editedCriteria || !parseResult) return
setStep(NeedBuilderStep.SAVING)
const fieldEntries = Object.entries(parseResult.confidenceByField)
const overallConfidence = fieldEntries.length > 0
? fieldEntries.reduce((sum, [, v]) => sum + v, 0) / fieldEntries.length
: 0
const entries = Object.entries(parseResult.confidenceByField)
const conf = entries.length > 0 ? entries.reduce((s, [, v]) => s + v, 0) / entries.length : 0
try {
const input = buildNeedInput(editedCriteria, weights, needTitle, overallConfidence)
await needService.create(input)
setStep(NeedBuilderStep.SAVED)
navigate('/demand/results', { state: { fromNeedBuilder: true } })
const input = buildNeedInput(editedCriteria, weights, needTitle, conf, 'ACTIVE')
const created = await needService.create(input)
await queryClient.invalidateQueries({ queryKey: ['needs'] })
await queryClient.invalidateQueries({ queryKey: ['matches'] })
navigate('/demand/results', { state: { fromNeedBuilder: true, activeNeedId: created.data.id } })
} catch {
setError('Speichern fehlgeschlagen. Bitte versuchen Sie es erneut.')
setError('Speichern fehlgeschlagen.')
setStep(NeedBuilderStep.ERROR)
}
}
@@ -124,7 +228,13 @@ export default function AISearch() {
setEditedCriteria(null)
}
const isReview = step === NeedBuilderStep.PARSED_REQUIRES_REVIEW || step === NeedBuilderStep.CLARIFICATION_REQUIRED
const hasStructuredData = !!(
criteria.assetType ||
(criteria.areaRange?.min ?? 0) > 0 ||
(criteria.preferredLocations?.length ?? 0) > 0
)
const canProceed = hasStructuredData || inputText.trim().length > 0
const isProcessing = step === NeedBuilderStep.PARSING || step === NeedBuilderStep.SAVING
const isSaveStep = step === NeedBuilderStep.READY_TO_SAVE || step === NeedBuilderStep.SAVING
const overallConfidence = parseResult
@@ -136,82 +246,103 @@ export default function AISearch() {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<PageHeader
title="AI Bedarfsanalyse"
subtitle="Beschreiben Sie Ihren Flächenbedarf in natürlicher Sprache"
/>
{/* Header */}
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, flexShrink: 0 }}>
<Typography variant="h5" sx={{ fontWeight: 700 }}>Flächensuche</Typography>
<Typography variant="body2" color="text.secondary">
Sprechen, schreiben oder Felder ausfüllen dann sofort suchen oder als Suchprofil speichern
</Typography>
</Box>
<NeedBuilderProgress step={step} />
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
{/* Step: Input */}
{step === NeedBuilderStep.IDLE && (
<NeedInput value={inputText} onChange={setInputText} onSubmit={handleAnalyze} />
)}
{/* ── IDLE: full form ── */}
{(step === NeedBuilderStep.IDLE || step === NeedBuilderStep.PARSING) && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 1200, mx: 'auto' }}>
{/* Step: Parsing */}
{step === NeedBuilderStep.PARSING && (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', py: 16, gap: 3 }}>
<CircularProgress size={48} sx={{ color: '#1e3a5f' }} />
<Typography variant="h6" color="text.secondary">KI analysiert Ihren Bedarf</Typography>
<Typography variant="caption" color="text.secondary">Kriterien werden extrahiert und bewertet</Typography>
</Box>
)}
<VoiceNeedInput
text={inputText}
onTextChange={handleTextChange}
isAutoGen={isAutoGen}
onAiSubmit={handleAiAutofill}
isAnalyzing={step === NeedBuilderStep.PARSING}
/>
{/* Step: Criteria Review + Follow-up */}
{isReview && parseResult && editedCriteria && (
<Box>
<Box className="grid grid-cols-2 gap-4" sx={{ mb: 2 }}>
<CriteriaReviewPanel
result={parseResult}
criteria={editedCriteria}
onCriteriaChange={setEditedCriteria}
/>
<FollowUpPanel
questions={parseResult.followUpQuestionCandidates}
answers={answers}
onAnswer={handleAnswer}
onContinue={() => setStep(NeedBuilderStep.WEIGHTING_REVIEW)}
onReparse={handleReparse}
<Box className="grid grid-cols-2 gap-4" sx={{ alignItems: 'start' }}>
<NeedInput criteria={criteria} onCriteriaChange={handleCriteriaChange} />
<WeightingEditor
key={weightingKey}
weights={weights}
onChange={setWeights}
assetType={criteria.assetType}
/>
</Box>
<Button
variant="outlined"
startIcon={<ArrowLeft size={16} />}
onClick={() => setStep(NeedBuilderStep.IDLE)}
>
Neu eingeben
</Button>
</Box>
)}
{/* Step: Weighting */}
{step === NeedBuilderStep.WEIGHTING_REVIEW && (
<Box>
<WeightingEditor
weights={weights}
onChange={setWeights}
assetType={editedCriteria?.assetType}
/>
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', justifyContent: 'space-between', mt: 3 }}>
<Button variant="outlined" startIcon={<ArrowLeft size={16} />} onClick={() => setStep(NeedBuilderStep.PARSED_REQUIRES_REVIEW)}>
Zurück
</Button>
{/* Action bar */}
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<Button
variant="contained"
endIcon={<ArrowRight size={16} />}
onClick={() => setStep(NeedBuilderStep.READY_TO_SAVE)}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
size="large"
disabled={!canProceed || isProcessing}
onClick={() => handleAction('search')}
endIcon={
isProcessing && intent === 'search'
? <CircularProgress size={18} color="inherit" />
: <Search size={18} />
}
sx={{
flex: 1,
py: 1.5,
bgcolor: '#1e3a5f',
'&:hover': { bgcolor: '#162d4a' },
fontSize: 15,
fontWeight: 600,
textTransform: 'none',
}}
>
Vorschau & Speichern
{isProcessing && intent === 'search' ? 'Sucht…' : 'Jetzt suchen'}
</Button>
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
<Button
variant="outlined"
size="large"
disabled={!canProceed || isProcessing}
onClick={() => handleAction('save-profile')}
startIcon={<Bookmark size={16} />}
endIcon={
isProcessing && intent === 'save-profile'
? <CircularProgress size={18} color="inherit" />
: <ArrowRight size={18} />
}
sx={{
py: 1.5,
fontSize: 15,
fontWeight: 500,
textTransform: 'none',
whiteSpace: 'nowrap',
}}
>
{isProcessing && intent === 'save-profile' ? 'Analysiert…' : 'Als Suchprofil speichern'}
</Button>
</Box>
<Alert severity="info" sx={{ mt: -1 }}>
<strong>Jetzt suchen</strong> liefert sofortige Ergebnisse.{' '}
<strong>Als Suchprofil speichern</strong> legt einen dauerhaften Bedarf an, der automatisch mit neuen Angeboten abgeglichen wird auch in Zukunft.
</Alert>
</Box>
)}
{/* Step: Preview + Save */}
{/* ── Preview + Save as Profile ── */}
{isSaveStep && parseResult && editedCriteria && (
<Box>
<Box sx={{ maxWidth: 1200, mx: 'auto' }}>
<Alert severity="success" sx={{ mb: 3 }}>
Dieses Suchprofil wird als aktiver Bedarf gespeichert und erscheint automatisch im Match Center der Verwaltung.
</Alert>
<NeedCardPreview
criteria={editedCriteria}
weights={weights}
@@ -223,34 +354,33 @@ export default function AISearch() {
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', justifyContent: 'space-between', mt: 3 }}>
<Button
variant="outlined"
startIcon={<ArrowLeft size={16} />}
onClick={() => setStep(NeedBuilderStep.WEIGHTING_REVIEW)}
onClick={() => setStep(NeedBuilderStep.IDLE)}
disabled={step === NeedBuilderStep.SAVING}
>
Zurück
Zurück
</Button>
<Button
variant="contained"
onClick={handleSave}
onClick={handleSaveProfile}
disabled={step === NeedBuilderStep.SAVING}
endIcon={
step === NeedBuilderStep.SAVING
? <CircularProgress size={16} color="inherit" />
: <Save size={16} />
}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none' }}
>
{step === NeedBuilderStep.SAVING
? 'Wird gespeichert…'
: overallConfidence < 0.6
? 'Als Entwurf speichern'
: 'Bedarf speichern & Suche starten'}
: 'Suchprofil speichern & Matching starten'}
</Button>
</Box>
</Box>
)}
{/* Step: Error */}
{/* ── Error ── */}
{step === NeedBuilderStep.ERROR && (
<NeedBuilderErrorState message={error ?? 'Unbekannter Fehler'} onRetry={handleRetry} />
)}
+2
View File
@@ -10,6 +10,7 @@ import { useShortlistStore } from '../../stores/shortlistStore'
import { AddToShortlistDialog } from '../../components/shortlist'
import { MatchReasonList } from '../../components/match-card/MatchReasonList'
import {
LocationIntelligencePanel,
MatchDetailHeader,
ExecutiveSummaryPanel,
PropertyOverviewPanel,
@@ -170,6 +171,7 @@ export default function MatchDetail() {
</Paper>
)}
<LocationIntelligencePanel property={property} />
<TradeoffPanel match={match} />
<RiskPanel match={match} />
<MissingInformationPanel match={match} />
+1 -1
View File
@@ -37,7 +37,7 @@ export default function MarketIntelligence() {
onFiltersChange={setFilters}
/>
</Box>
<Box sx={{ flex: 1, overflow: 'hidden' }}>
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<MarketSignalDetailPanel signal={selectedSignal} />
</Box>
</Box>
+1 -1
View File
@@ -37,7 +37,7 @@ export default function SignalPipeline() {
onFiltersChange={setFilters}
/>
</Box>
<Box sx={{ flex: 1, overflow: 'hidden' }}>
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{selectedSignal
? <SignalPipelineView signal={selectedSignal} />
: <MarketSignalEmptyState variant="no-selection" />
+1 -1
View File
@@ -40,7 +40,7 @@ export default function SourceMonitoring() {
/>
</Box>
{/* Right: Detail panel */}
<Box sx={{ flex: 1, overflow: 'hidden' }}>
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<SourceDetailPanel source={selectedSource} />
</Box>
</Box>
+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>
)
}