5035445d2e
All page-level headers now use fontSize:1.125rem, fontWeight:700, color:#0f172a, container py:2.5, borderBottom:#e8e7e4 — matching the standard established in PageHeader. Removes variant="h5"/h6" inconsistencies across Supply, Demand, and shared components. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
202 lines
7.0 KiB
TypeScript
202 lines
7.0 KiB
TypeScript
import { useMemo, useState } from 'react'
|
||
import {
|
||
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 { useToastStore } from '../../stores/toastStore'
|
||
import { MatchListCard, MatchBriefingPanel, MatchCenterSkeleton } from '../../components/match-center'
|
||
import { ErrorState } from '../../components/ui'
|
||
import type { Match } from '../../domain/match'
|
||
|
||
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 = [], isLoading, isError, refetch } = useMatches()
|
||
const { data: properties = [] } = useProperties()
|
||
const { data: needs = [] } = useNeeds()
|
||
const { setSelectedProperty, setSelectedNeed } = useMatchCenterStore()
|
||
const approveMatch = useApproveMatch()
|
||
const showToast = useToastStore((s) => s.showToast)
|
||
|
||
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', flexDirection: 'column', height: 'calc(100vh - 64px)', overflow: 'hidden' }}>
|
||
|
||
{/* Header */}
|
||
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e8e7e4', px: 3, py: 2.5, flexShrink: 0 }}>
|
||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 2, mb: 1 }}>
|
||
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>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>
|
||
</Box>
|
||
|
||
{/* 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 />
|
||
) : isError ? (
|
||
<ErrorState onRetry={refetch} />
|
||
) : 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, {
|
||
onSuccess: () => showToast('Match genehmigt.'),
|
||
onError: () => showToast('Genehmigung fehlgeschlagen.', 'error'),
|
||
})}
|
||
/>
|
||
))}
|
||
</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>
|
||
</Drawer>
|
||
</Box>
|
||
)
|
||
}
|