Files
property-match/src/pages/supply/MatchCenter.tsx
T
Benjamin Sutter 6515acb7f0 feat: unified error handling + AI service modularisation
Error handling (Prompt 2):
- src/services/errors.ts: AppError class, normalizeError(), throwServiceError() helper
- 6 services wrapped with try/catch (property, match, need, shortlist, futureSignal, inquiry)
- inquiryService aligned from custom ServiceResult<T> to standard ServiceResponse types
- Results, MatchCenter, FutureAvailability pages show <ErrorState onRetry> on query failure

AI modularisation (Prompt 3):
- src/services/aiService.ts reduced from 755 → 19 lines (barrel re-export)
- src/services/ai/IAIService.ts: typed interface + all response types
- src/services/ai/mock/: needParser, compareBuilder, decisionBrief, listingParser, MockAIService
- src/services/ai/openrouter/OpenRouterAIService.ts: model-agnostic skeleton
- src/services/ai/prompts/: 4 prompt template files (needParsing, matchExplanation, compareSummary, decisionBrief)
- src/services/ai/index.ts: factory selects Mock or OpenRouter via VITE_USE_REAL_AI flag
- All existing import paths unchanged — zero call-site modifications

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 00:32:01 +02:00

202 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (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 = [], 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 #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>
</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>
)
}