7cf8d8ba72
Full decision analysis page at /demand/results/:matchId: - MatchDetailHeader: score, badges, CTA (compare/shortlist), back button - ExecutiveSummaryPanel: 4-row summary (fit, top reason, tradeoff, next step) - PropertyOverviewPanel: key facts grid; signal-aware for FUTURE_AVAILABILITY - NeedAlignmentPanel: comparison table with fit indicators (MATCH/PARTIAL/NO_MATCH) - ScoreBreakdownPanel: hard/soft scores + modifiers + total (sidebar) - TradeoffPanel: severity-coded list with mitigation hints - RiskPanel: risks sorted CRITICAL→LOW with category chips - MissingInformationPanel: priority-grouped with per-item CTAs - SourceProvenancePanel: source type, label, URL, freshness - FutureAvailabilityContextPanel: mandatory disclaimer + signal metadata - NextActionsPanel: engine-ranked actions + standard actions (sidebar) - Details button on result cards now navigates to match detail Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
159 lines
5.5 KiB
TypeScript
159 lines
5.5 KiB
TypeScript
import { Box, CircularProgress, Paper, Typography } from '@mui/material'
|
|
import { useNavigate, useParams } from 'react-router'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { useMatchDetail } from '../../hooks/useMatches'
|
|
import { propertyService } from '../../services/propertyService'
|
|
import { needService } from '../../services/needService'
|
|
import { futureSignalService } from '../../services/futureSignalService'
|
|
import { useCompareStore } from '../../stores/compareStore'
|
|
import { MatchReasonList } from '../../components/match-card/MatchReasonList'
|
|
import {
|
|
MatchDetailHeader,
|
|
ExecutiveSummaryPanel,
|
|
PropertyOverviewPanel,
|
|
NeedAlignmentPanel,
|
|
ScoreBreakdownPanel,
|
|
TradeoffPanel,
|
|
RiskPanel,
|
|
MissingInformationPanel,
|
|
SourceProvenancePanel,
|
|
FutureAvailabilityContextPanel,
|
|
NextActionsPanel,
|
|
} from '../../components/match-detail'
|
|
import type { MatchCardReason } from '../../components/match-card/MatchCardViewModel'
|
|
|
|
const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
|
|
|
|
function buildReasons(match: NonNullable<ReturnType<typeof useMatchDetail>['data']>): MatchCardReason[] {
|
|
return match.positiveFactors.slice(0, 3).map(f => ({
|
|
type: (HARD_CRITERIA.has(f.criterion) ? 'HARD_FACT' : 'SOFT_FACTOR') as MatchCardReason['type'],
|
|
label: f.criterion.charAt(0).toUpperCase() + f.criterion.slice(1),
|
|
explanation: f.explanation,
|
|
score: f.score,
|
|
}))
|
|
}
|
|
|
|
export default function MatchDetail() {
|
|
const { matchId } = useParams<{ matchId: string }>()
|
|
const navigate = useNavigate()
|
|
const { addToCompare } = useCompareStore()
|
|
|
|
const { data: match, isLoading } = useMatchDetail(matchId ?? '')
|
|
|
|
const isFuture = match?.resultType === 'FUTURE_AVAILABILITY'
|
|
|
|
const { data: property = null } = useQuery({
|
|
queryKey: ['property', match?.propertyId],
|
|
queryFn: () => propertyService.getById(match!.propertyId),
|
|
enabled: !!match && !isFuture,
|
|
select: r => r.data ?? null,
|
|
})
|
|
|
|
const { data: need = null } = useQuery({
|
|
queryKey: ['need', match?.needId],
|
|
queryFn: () => needService.getById(match!.needId),
|
|
enabled: !!match?.needId,
|
|
select: r => r.data ?? null,
|
|
})
|
|
|
|
const { data: signal = null } = useQuery({
|
|
queryKey: ['signal', match?.resultId],
|
|
queryFn: () => futureSignalService.getById(match!.resultId!),
|
|
enabled: !!match && isFuture && !!match.resultId,
|
|
select: r => r.data ?? null,
|
|
})
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 12 }}>
|
|
<CircularProgress />
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
if (!match) {
|
|
return (
|
|
<Box sx={{ px: 3, py: 4 }}>
|
|
<Paper sx={{ p: 4, textAlign: 'center' }}>
|
|
<Typography variant="h6" color="text.secondary">Match nicht gefunden</Typography>
|
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
|
Das gesuchte Match existiert nicht oder wurde entfernt.
|
|
</Typography>
|
|
</Paper>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
const reasons = buildReasons(match)
|
|
const compareId = property?.id ?? ''
|
|
|
|
const handleCompare = () => {
|
|
if (compareId) addToCompare(compareId)
|
|
navigate('/demand/compare')
|
|
}
|
|
|
|
const handleBack = () => navigate(-1)
|
|
|
|
return (
|
|
<Box sx={{ px: 3, py: 2 }}>
|
|
<MatchDetailHeader
|
|
match={match}
|
|
property={property}
|
|
signal={signal}
|
|
onBack={handleBack}
|
|
onCompare={handleCompare}
|
|
onShortlist={() => {}}
|
|
/>
|
|
|
|
<Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start' }}>
|
|
{/* Main column */}
|
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
|
<ExecutiveSummaryPanel match={match} />
|
|
<PropertyOverviewPanel match={match} property={property} signal={signal} />
|
|
<NeedAlignmentPanel match={match} need={need} property={property} />
|
|
|
|
{/* Why It Matches — structured from scoreFactors */}
|
|
{reasons.length > 0 && (
|
|
<Paper sx={{ p: 2.5, mb: 2 }}>
|
|
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Warum dieses Match</Typography>
|
|
<MatchReasonList reasons={reasons} maxItems={3} />
|
|
{match.negativeFactors.length > 0 && (
|
|
<Box sx={{ mt: 1.5 }}>
|
|
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block', mb: 0.75 }}>
|
|
Schwächere Faktoren
|
|
</Typography>
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
|
{match.negativeFactors.slice(0, 3).map((f, i) => (
|
|
<Typography key={i} variant="body2" color="text.secondary">
|
|
· {f.criterion}: {f.explanation}
|
|
</Typography>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
</Paper>
|
|
)}
|
|
|
|
<TradeoffPanel match={match} />
|
|
<RiskPanel match={match} />
|
|
<MissingInformationPanel match={match} />
|
|
<SourceProvenancePanel property={property} />
|
|
{isFuture && <FutureAvailabilityContextPanel match={match} signal={signal} />}
|
|
</Box>
|
|
|
|
{/* Sidebar — sticky */}
|
|
<Box sx={{ width: 320, flexShrink: 0, position: 'sticky', top: 24 }}>
|
|
<ScoreBreakdownPanel match={match} />
|
|
<NextActionsPanel
|
|
match={match}
|
|
onCompare={handleCompare}
|
|
onShortlist={() => {}}
|
|
onReview={() => {}}
|
|
onReject={() => {}}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
)
|
|
}
|