feat: F012 match detail view
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>
This commit is contained in:
@@ -15,6 +15,7 @@ const DataQuality = lazy(() => import('./pages/supply/DataQuality'))
|
||||
|
||||
const AISearch = lazy(() => import('./pages/demand/AISearch'))
|
||||
const Results = lazy(() => import('./pages/demand/Results'))
|
||||
const MatchDetail = lazy(() => import('./pages/demand/MatchDetail'))
|
||||
const Compare = lazy(() => import('./pages/demand/Compare'))
|
||||
const Shortlists = lazy(() => import('./pages/demand/Shortlists'))
|
||||
|
||||
@@ -52,6 +53,7 @@ function App() {
|
||||
<Route element={<ProtectedRoute workspace={WorkspaceType.DEMAND} />}>
|
||||
<Route path="/demand/ai-search" element={<AISearch />} />
|
||||
<Route path="/demand/results" element={<Results />} />
|
||||
<Route path="/demand/results/:matchId" element={<MatchDetail />} />
|
||||
<Route path="/demand/compare" element={<Compare />} />
|
||||
<Route path="/demand/shortlists" element={<Shortlists />} />
|
||||
</Route>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Box, Paper, Typography } from '@mui/material'
|
||||
import { CheckCircle2, AlertTriangle, ArrowRight, Target } from 'lucide-react'
|
||||
import type { Match } from '../../domain/match'
|
||||
|
||||
interface Props {
|
||||
match: Match
|
||||
}
|
||||
|
||||
export function ExecutiveSummaryPanel({ match }: Props) {
|
||||
const topReason = match.positiveFactors[0]
|
||||
const topTradeoff = match.tradeoffs?.[0]
|
||||
const nextStep = match.nextBestActions?.[0]
|
||||
|
||||
const rows: { icon: React.ReactNode; label: string; text: string }[] = [
|
||||
{
|
||||
icon: <Target size={15} color="#1e3a5f" />,
|
||||
label: 'Gesamteignung',
|
||||
text: match.explainabilitySummary || `${match.matchStrength}-Match mit ${match.matchScore} Punkten`,
|
||||
},
|
||||
...(topReason ? [{
|
||||
icon: <CheckCircle2 size={15} color="#1a7a4a" />,
|
||||
label: 'Stärkster Grund',
|
||||
text: topReason.explanation,
|
||||
}] : []),
|
||||
...(topTradeoff ? [{
|
||||
icon: <AlertTriangle size={15} color="#d97706" />,
|
||||
label: 'Hauptabwägung',
|
||||
text: topTradeoff.concern,
|
||||
}] : []),
|
||||
...(nextStep ? [{
|
||||
icon: <ArrowRight size={15} color="#7c3aed" />,
|
||||
label: 'Empfohlener nächster Schritt',
|
||||
text: nextStep.description ?? nextStep.label,
|
||||
}] : []),
|
||||
]
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2.5, mb: 2, bgcolor: '#f8fafc', border: '1px solid #e2e8f0' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Executive Summary</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
|
||||
{rows.map((row, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
||||
<Box sx={{ mt: 0.25, flexShrink: 0 }}>{row.icon}</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block' }}>
|
||||
{row.label}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{row.text}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Alert, Box, Chip, Paper, Typography } from '@mui/material'
|
||||
import type { Match } from '../../domain/match'
|
||||
import type { FutureSignal } from '../../domain/futureSignal'
|
||||
|
||||
const SIGNAL_TYPE_LABELS: Record<string, string> = {
|
||||
EXPANSION: 'Expansion',
|
||||
POSSIBLE_MOVE_OUT: 'Möglicher Auszug',
|
||||
CONSTRUCTION_PROJECT: 'Bauvorhaben',
|
||||
RESTRUCTURING: 'Restrukturierung',
|
||||
PROJECT_DEVELOPMENT: 'Projektentwicklung',
|
||||
SPACE_CONSOLIDATION: 'Flächenkonsolidierung',
|
||||
}
|
||||
|
||||
const SENSITIVITY_META: Record<string, { label: string; color: 'error' | 'warning' | 'default' }> = {
|
||||
CONFIDENTIAL: { label: 'Vertraulich', color: 'error' },
|
||||
INTERNAL: { label: 'Intern', color: 'warning' },
|
||||
PUBLIC: { label: 'Öffentlich', color: 'default' },
|
||||
}
|
||||
|
||||
const REVIEW_STATUS_META: Record<string, { label: string; color: 'warning' | 'success' | 'default' | 'error' }> = {
|
||||
UNREVIEWED: { label: 'Ungeprüft', color: 'warning' },
|
||||
IN_REVIEW: { label: 'In Prüfung', color: 'warning' },
|
||||
APPROVED: { label: 'Genehmigt', color: 'success' },
|
||||
REJECTED: { label: 'Abgelehnt', color: 'error' },
|
||||
FLAGGED: { label: 'Markiert', color: 'warning' },
|
||||
}
|
||||
|
||||
interface Props {
|
||||
match: Match
|
||||
signal: FutureSignal | null
|
||||
}
|
||||
|
||||
export function FutureAvailabilityContextPanel({ match: _match, signal }: Props) {
|
||||
if (!signal) return null
|
||||
|
||||
const sensitiveMeta = SENSITIVITY_META[signal.sensitivityLevel] ?? SENSITIVITY_META.PUBLIC
|
||||
const reviewMeta = signal.reviewStatus ? (REVIEW_STATUS_META[signal.reviewStatus] ?? null) : null
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2.5, mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Zukunftssignal – Kontext</Typography>
|
||||
|
||||
{/* Mandatory disclaimer */}
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
{signal.disclaimer}
|
||||
</Alert>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{signal.signalType && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 160 }}>Signaltyp</Typography>
|
||||
<Typography variant="body2">{SIGNAL_TYPE_LABELS[signal.signalType] ?? signal.signalType}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 160 }}>Konfidenz</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{Math.round(signal.confidenceScore * 100)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 160 }}>Wahrscheinlichkeit</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{Math.round(signal.probability * 100)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 160 }}>Zeithorizont</Typography>
|
||||
<Typography variant="body2">~{signal.timeHorizonMonths} Monate</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 160 }}>Vertraulichkeit</Typography>
|
||||
<Chip label={sensitiveMeta.label} size="small" color={sensitiveMeta.color} variant="outlined" />
|
||||
</Box>
|
||||
{reviewMeta && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 160 }}>Prüfstatus</Typography>
|
||||
<Chip label={reviewMeta.label} size="small" color={reviewMeta.color} />
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 160 }}>Quellglaubwürdigkeit</Typography>
|
||||
<Typography variant="body2">{signal.source.credibility}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{signal.evidence?.summary && (
|
||||
<Box sx={{ mt: 1.5, p: 1.5, bgcolor: '#f8fafc', borderRadius: 1 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
|
||||
Evidenz
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{signal.evidence.summary}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Alert, Box, Button, Chip, Paper, Typography } from '@mui/material'
|
||||
import { ArrowLeft, Bookmark, Columns2 } from 'lucide-react'
|
||||
import { MatchScoreDisplay } from '../match-card/MatchScoreDisplay'
|
||||
import type { Match } from '../../domain/match'
|
||||
import type { Property } from '../../domain/property'
|
||||
import type { FutureSignal } from '../../domain/futureSignal'
|
||||
|
||||
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
|
||||
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
|
||||
EXTERNAL_MARKET: { label: 'Marktinserat', color: '#d97706' },
|
||||
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
|
||||
}
|
||||
|
||||
function confColor(c: number): string {
|
||||
return c >= 0.75 ? '#1a7a4a' : c >= 0.55 ? '#d97706' : '#c0392b'
|
||||
}
|
||||
|
||||
function dqColor(q: number): string {
|
||||
return q >= 0.80 ? '#1a7a4a' : q >= 0.60 ? '#d97706' : '#c0392b'
|
||||
}
|
||||
|
||||
interface Props {
|
||||
match: Match
|
||||
property: Property | null
|
||||
signal: FutureSignal | null
|
||||
onBack: () => void
|
||||
onCompare: () => void
|
||||
onShortlist: () => void
|
||||
}
|
||||
|
||||
export function MatchDetailHeader({ match, property, signal, onBack, onCompare, onShortlist }: Props) {
|
||||
const rt = RESULT_TYPE_META[match.resultType ?? 'VERIFIED_PORTFOLIO'] ?? { label: '–', color: '#64748b' }
|
||||
const title = property?.title ?? signal?.companyName ?? signal?.locationHint ?? '–'
|
||||
const isFuture = match.resultType === 'FUTURE_AVAILABILITY'
|
||||
const dqScore = property?.dataQuality?.score ?? signal?.confidenceScore ?? 0.5
|
||||
const confPct = Math.round(match.confidenceLevel * 100)
|
||||
const dqPct = Math.round(dqScore * 100)
|
||||
const location = property?.location?.city
|
||||
? `${property.location.city}${property.location.district ? `, ${property.location.district}` : ''}`
|
||||
: signal?.locationHint ?? '–'
|
||||
const source = property?.sourceLabel ?? property?.sourceMeta?.sourceLabel ?? '–'
|
||||
const availability = property?.availabilityDate ?? (signal?.timeHorizonMonths ? `~${signal.timeHorizonMonths} Monate` : undefined)
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 3, mb: 2 }}>
|
||||
{isFuture && (
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
Probabilistisches Signal – keine bestätigte Fläche. Alle Angaben sind Schätzungen.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
startIcon={<ArrowLeft size={16} />}
|
||||
onClick={onBack}
|
||||
size="small"
|
||||
sx={{ mb: 1.5, color: '#64748b' }}
|
||||
>
|
||||
Zurück zu Resultaten
|
||||
</Button>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 0.5 }}>{title}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>{location}</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, alignItems: 'center' }}>
|
||||
<Chip label={rt.label} size="small" sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600 }} />
|
||||
{property?.assetType && (
|
||||
<Chip label={property.assetType} size="small" variant="outlined" />
|
||||
)}
|
||||
<Chip
|
||||
label={`${confPct}% Konfidenz`}
|
||||
size="small"
|
||||
sx={{ bgcolor: confColor(match.confidenceLevel), color: 'white' }}
|
||||
/>
|
||||
<Chip
|
||||
label={`DQ ${dqPct}%`}
|
||||
size="small"
|
||||
sx={{ bgcolor: dqColor(dqScore), color: 'white' }}
|
||||
/>
|
||||
{availability && <Chip label={availability} size="small" variant="outlined" />}
|
||||
{source !== '–' && (
|
||||
<Typography variant="caption" color="text.secondary">Quelle: {source}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 1.5 }}>
|
||||
<MatchScoreDisplay score={match.matchScore} size="lg" />
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<Bookmark size={14} />}
|
||||
onClick={onShortlist}
|
||||
>
|
||||
Shortlist
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<Columns2 size={14} />}
|
||||
onClick={onCompare}
|
||||
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
|
||||
>
|
||||
Vergleichen
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Box, Button, Chip, Paper, Typography } from '@mui/material'
|
||||
import { FileQuestion } from 'lucide-react'
|
||||
import type { Match, MissingDataItem } from '../../domain/match'
|
||||
|
||||
const IMPORTANCE_ORDER = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']
|
||||
const IMPORTANCE_META: Record<string, { label: string; color: 'error' | 'warning' | 'default' }> = {
|
||||
CRITICAL: { label: 'Kritisch', color: 'error' },
|
||||
HIGH: { label: 'Wichtig', color: 'warning' },
|
||||
MEDIUM: { label: 'Optional', color: 'default' },
|
||||
LOW: { label: 'Optional', color: 'default' },
|
||||
}
|
||||
|
||||
interface MissingItemRowProps {
|
||||
item: MissingDataItem
|
||||
}
|
||||
|
||||
function MissingItemRow({ item }: MissingItemRowProps) {
|
||||
const meta = IMPORTANCE_META[item.importance] ?? IMPORTANCE_META.LOW
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 1.5, border: '1px solid #e2e8f0', borderRadius: 1, mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<FileQuestion size={14} color="#64748b" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, flex: 1 }}>{item.field}</Typography>
|
||||
<Chip label={meta.label} size="small" color={meta.color} variant="outlined" />
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
|
||||
{item.description}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1, fontStyle: 'italic' }}>
|
||||
Auswirkung: {item.impact}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.75 }}>
|
||||
<Button size="small" variant="outlined" sx={{ fontSize: 11, py: 0.25 }}>
|
||||
Daten anfordern
|
||||
</Button>
|
||||
<Button size="small" variant="text" sx={{ fontSize: 11, py: 0.25, color: '#64748b' }}>
|
||||
Nicht relevant
|
||||
</Button>
|
||||
<Button size="small" variant="text" sx={{ fontSize: 11, py: 0.25, color: '#64748b' }}>
|
||||
Zur Prüfung
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
match: Match
|
||||
}
|
||||
|
||||
export function MissingInformationPanel({ match }: Props) {
|
||||
const missingData: MissingDataItem[] = match.missingData ?? []
|
||||
if (missingData.length === 0) return null
|
||||
|
||||
const sorted = [...missingData].sort(
|
||||
(a, b) => IMPORTANCE_ORDER.indexOf(a.importance) - IMPORTANCE_ORDER.indexOf(b.importance)
|
||||
)
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2.5, mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Fehlende Informationen</Typography>
|
||||
<Chip
|
||||
label={`${missingData.length} fehlend`}
|
||||
size="small"
|
||||
color={missingData.some(m => m.importance === 'CRITICAL') ? 'error' : 'warning'}
|
||||
/>
|
||||
</Box>
|
||||
{sorted.map((item, i) => (
|
||||
<MissingItemRow key={i} item={item} />
|
||||
))}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Box, Chip, Paper, Typography } from '@mui/material'
|
||||
import { CheckCircle2, XCircle, Minus } from 'lucide-react'
|
||||
import type { Match } from '../../domain/match'
|
||||
import type { Property } from '../../domain/property'
|
||||
import type { Need } from '../../domain/need'
|
||||
|
||||
type FitStatus = 'MATCH' | 'NO_MATCH' | 'PARTIAL' | 'UNKNOWN'
|
||||
|
||||
interface AlignmentRow {
|
||||
label: string
|
||||
needValue: string
|
||||
resultValue: string
|
||||
fit: FitStatus
|
||||
}
|
||||
|
||||
function fitIcon(fit: FitStatus) {
|
||||
if (fit === 'MATCH') return <CheckCircle2 size={16} color="#1a7a4a" />
|
||||
if (fit === 'NO_MATCH') return <XCircle size={16} color="#c0392b" />
|
||||
if (fit === 'PARTIAL') return <Minus size={16} color="#d97706" />
|
||||
return <Minus size={16} color="#94a3b8" />
|
||||
}
|
||||
|
||||
function fitColor(fit: FitStatus): string {
|
||||
if (fit === 'MATCH') return '#f0fdf4'
|
||||
if (fit === 'NO_MATCH') return '#fef2f2'
|
||||
if (fit === 'PARTIAL') return '#fffbeb'
|
||||
return '#f8fafc'
|
||||
}
|
||||
|
||||
function buildRows(need: Need, property: Property): AlignmentRow[] {
|
||||
const rows: AlignmentRow[] = []
|
||||
|
||||
// Area
|
||||
const areaSqm = property.areaSqm
|
||||
const areaFit: FitStatus = areaSqm >= need.requiredArea.min && areaSqm <= need.requiredArea.max
|
||||
? 'MATCH'
|
||||
: areaSqm >= need.requiredArea.min * 0.85
|
||||
? 'PARTIAL'
|
||||
: 'NO_MATCH'
|
||||
rows.push({
|
||||
label: 'Fläche',
|
||||
needValue: `${need.requiredArea.min}–${need.requiredArea.max} m²`,
|
||||
resultValue: `${areaSqm} m²`,
|
||||
fit: areaFit,
|
||||
})
|
||||
|
||||
// Location
|
||||
const locationFit: FitStatus = need.preferredLocations.some(
|
||||
l => l.toLowerCase() === property.location.city.toLowerCase() ||
|
||||
l.toLowerCase() === property.location.district?.toLowerCase()
|
||||
) ? 'MATCH' : 'PARTIAL'
|
||||
rows.push({
|
||||
label: 'Standort',
|
||||
needValue: need.preferredLocations.join(', ') || '–',
|
||||
resultValue: property.location.city,
|
||||
fit: locationFit,
|
||||
})
|
||||
|
||||
// Budget
|
||||
const budgetFit: FitStatus = property.rentPricePerSqm <= need.budgetRange.maxPerSqm
|
||||
? 'MATCH'
|
||||
: property.rentPricePerSqm <= need.budgetRange.maxPerSqm * 1.1
|
||||
? 'PARTIAL'
|
||||
: 'NO_MATCH'
|
||||
rows.push({
|
||||
label: 'Budget',
|
||||
needValue: `max. CHF ${need.budgetRange.maxPerSqm}/m²`,
|
||||
resultValue: `CHF ${property.rentPricePerSqm}/m²`,
|
||||
fit: budgetFit,
|
||||
})
|
||||
|
||||
// Timing
|
||||
const availDate = property.availabilityDate
|
||||
const latestMoveIn = need.timing.latestMoveIn
|
||||
const timingFit: FitStatus = !availDate ? 'UNKNOWN'
|
||||
: availDate <= latestMoveIn ? 'MATCH' : 'PARTIAL'
|
||||
rows.push({
|
||||
label: 'Verfügbarkeit',
|
||||
needValue: `bis ${need.timing.latestMoveIn}`,
|
||||
resultValue: availDate || 'unbekannt',
|
||||
fit: timingFit,
|
||||
})
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
interface Props {
|
||||
match: Match
|
||||
need: Need | null
|
||||
property: Property | null
|
||||
}
|
||||
|
||||
export function NeedAlignmentPanel({ match: _match, need, property }: Props) {
|
||||
if (!need || !property) return null
|
||||
|
||||
const rows = buildRows(need, property)
|
||||
const mustHaves = need.mustHaveCriteria ?? []
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2.5, mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Need-Alignment</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1.5 }}>
|
||||
Gesuch: {need.companyName} · {need.assetType}
|
||||
</Typography>
|
||||
|
||||
{/* Comparison table */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', gap: 0, bgcolor: '#f8fafc', px: 1.5, py: 0.75, borderRadius: 1, mb: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, flex: '0 0 130px' }}>Kriterium</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, flex: 1 }}>Gesuch</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, flex: 1 }}>Objekt</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, width: 28, textAlign: 'center' }}>Fit</Typography>
|
||||
</Box>
|
||||
{rows.map((row, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0,
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
bgcolor: fitColor(row.fit),
|
||||
borderRadius: 0.5,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, flex: '0 0 130px' }}>{row.label}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ flex: 1 }}>{row.needValue}</Typography>
|
||||
<Typography variant="body2" sx={{ flex: 1 }}>{row.resultValue}</Typography>
|
||||
<Box sx={{ width: 28, display: 'flex', justifyContent: 'center' }}>
|
||||
{fitIcon(row.fit)}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Must-haves */}
|
||||
{mustHaves.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block', mb: 0.75 }}>
|
||||
Must-have Kriterien
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{mustHaves.map((m, i) => (
|
||||
<Chip key={i} label={m.criterion} size="small" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Box, Button, Paper, Typography } from '@mui/material'
|
||||
import type { Match, NextBestAction } from '../../domain/match'
|
||||
|
||||
const PRIORITY_ORDER: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 }
|
||||
|
||||
const PRIORITY_COLOR: Record<string, 'contained' | 'outlined'> = {
|
||||
HIGH: 'contained',
|
||||
MEDIUM: 'outlined',
|
||||
LOW: 'outlined',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
match: Match
|
||||
onCompare?: () => void
|
||||
onShortlist?: () => void
|
||||
onReject?: () => void
|
||||
onReview?: () => void
|
||||
}
|
||||
|
||||
export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onReview }: Props) {
|
||||
const engineActions: NextBestAction[] = [...(match.nextBestActions ?? [])].sort(
|
||||
(a, b) => (PRIORITY_ORDER[a.priority] ?? 2) - (PRIORITY_ORDER[b.priority] ?? 2)
|
||||
)
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2.5, mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Empfohlene Aktionen</Typography>
|
||||
|
||||
{engineActions.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 1.5 }}>
|
||||
{engineActions.map((action, i) => (
|
||||
<Box key={i}>
|
||||
<Button
|
||||
fullWidth
|
||||
size="small"
|
||||
variant={PRIORITY_COLOR[action.priority]}
|
||||
sx={
|
||||
action.priority === 'HIGH'
|
||||
? { bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, justifyContent: 'flex-start' }
|
||||
: { justifyContent: 'flex-start' }
|
||||
}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
{action.description && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25, px: 1 }}>
|
||||
{action.description}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Standard actions */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
{onShortlist && (
|
||||
<Button fullWidth size="small" variant="outlined" onClick={onShortlist} sx={{ justifyContent: 'flex-start' }}>
|
||||
Zur Shortlist hinzufügen
|
||||
</Button>
|
||||
)}
|
||||
{onCompare && (
|
||||
<Button fullWidth size="small" variant="outlined" onClick={onCompare} sx={{ justifyContent: 'flex-start' }}>
|
||||
Zum Vergleich hinzufügen
|
||||
</Button>
|
||||
)}
|
||||
{onReview && (
|
||||
<Button fullWidth size="small" variant="outlined" onClick={onReview} sx={{ justifyContent: 'flex-start', color: '#d97706', borderColor: '#d97706' }}>
|
||||
Zur Überprüfung senden
|
||||
</Button>
|
||||
)}
|
||||
{onReject && (
|
||||
<Button fullWidth size="small" variant="outlined" onClick={onReject} sx={{ justifyContent: 'flex-start', color: '#c0392b', borderColor: '#c0392b' }}>
|
||||
Match ablehnen
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Box, Chip, Paper, Typography } from '@mui/material'
|
||||
import { Banknote, Calendar, MapPin, Maximize2, Tag } from 'lucide-react'
|
||||
import type { Match } from '../../domain/match'
|
||||
import type { Property } from '../../domain/property'
|
||||
import type { FutureSignal } from '../../domain/futureSignal'
|
||||
|
||||
interface FactRowProps {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
function FactRow({ icon, label, value }: FactRowProps) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, py: 1, borderBottom: '1px solid #f1f5f9' }}>
|
||||
<Box sx={{ color: '#64748b', flexShrink: 0 }}>{icon}</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 140 }}>{label}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>{value}</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
match: Match
|
||||
property: Property | null
|
||||
signal: FutureSignal | null
|
||||
}
|
||||
|
||||
export function PropertyOverviewPanel({ match, property, signal }: Props) {
|
||||
if (!property && !signal) return null
|
||||
|
||||
const isFuture = match.resultType === 'FUTURE_AVAILABILITY'
|
||||
|
||||
const rows: FactRowProps[] = isFuture ? [
|
||||
{ icon: <MapPin size={15} />, label: 'Standorthinweis', value: signal?.locationHint ?? '–' },
|
||||
{ icon: <Maximize2 size={15} />, label: 'Flächenschätzung', value: signal?.areaSqmEstimate ? `~${signal.areaSqmEstimate} m²` : 'unbekannt' },
|
||||
{ icon: <Calendar size={15} />, label: 'Zeithorizont', value: signal?.timeHorizonMonths ? `~${signal.timeHorizonMonths} Monate` : '–' },
|
||||
{ icon: <Tag size={15} />, label: 'Wahrscheinlichkeit', value: signal?.probability ? `${Math.round(signal.probability * 100)}%` : '–' },
|
||||
] : [
|
||||
{ icon: <MapPin size={15} />, label: 'Standort', value: property ? `${property.location.city}${property.location.district ? `, ${property.location.district}` : ''}` : '–' },
|
||||
{ icon: <Maximize2 size={15} />, label: 'Nutzfläche', value: property ? `${property.areaSqm} m²` : '–' },
|
||||
{ icon: <Banknote size={15} />, label: 'Mietpreis', value: property?.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²` : '–' },
|
||||
{ icon: <Calendar size={15} />, label: 'Verfügbar ab', value: property?.availabilityDate ?? '–' },
|
||||
{ icon: <Tag size={15} />, label: 'Objekttyp', value: property?.assetType ?? '–' },
|
||||
]
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2.5, mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>
|
||||
{isFuture ? 'Signal-Übersicht' : 'Objekt-Übersicht'}
|
||||
</Typography>
|
||||
{property?.status && (
|
||||
<Chip label={property.status} size="small" variant="outlined" />
|
||||
)}
|
||||
</Box>
|
||||
{rows.map((row, i) => (
|
||||
<FactRow key={i} {...row} />
|
||||
))}
|
||||
{property?.description && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1.5, fontStyle: 'italic' }}>
|
||||
{property.description}
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Box, Chip, Paper, Typography } from '@mui/material'
|
||||
import { ShieldAlert } from 'lucide-react'
|
||||
import type { Match } from '../../domain/match'
|
||||
import type { Risk } from '../../domain/match'
|
||||
|
||||
const LEVEL_ORDER = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']
|
||||
const LEVEL_META: Record<string, { label: string; color: 'error' | 'warning' | 'success'; bgcolor: string; border: string }> = {
|
||||
CRITICAL: { label: 'Kritisch', color: 'error', bgcolor: '#fef2f2', border: '#fca5a5' },
|
||||
HIGH: { label: 'Hoch', color: 'error', bgcolor: '#fff7ed', border: '#fed7aa' },
|
||||
MEDIUM: { label: 'Mittel', color: 'warning', bgcolor: '#fffbeb', border: '#fde68a' },
|
||||
LOW: { label: 'Gering', color: 'success', bgcolor: '#f0fdf4', border: '#bbf7d0' },
|
||||
}
|
||||
|
||||
interface Props {
|
||||
match: Match
|
||||
}
|
||||
|
||||
export function RiskPanel({ match }: Props) {
|
||||
const risks: Risk[] = match.risks ?? []
|
||||
if (risks.length === 0) return null
|
||||
|
||||
const sorted = [...risks].sort(
|
||||
(a, b) => LEVEL_ORDER.indexOf(a.level) - LEVEL_ORDER.indexOf(b.level)
|
||||
)
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2.5, mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Risiken & Unsicherheiten</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
|
||||
{sorted.map((r, i) => {
|
||||
const meta = LEVEL_META[r.level] ?? LEVEL_META.LOW
|
||||
return (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{ p: 1.5, bgcolor: meta.bgcolor, border: `1px solid ${meta.border}`, borderRadius: 1 }}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<ShieldAlert size={15} color={meta.color === 'error' ? '#dc2626' : '#d97706'} />
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, flex: 1 }}>{r.category}</Typography>
|
||||
<Chip label={meta.label} size="small" color={meta.color} />
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: r.mitigation ? 0.5 : 0 }}>
|
||||
{r.description}
|
||||
</Typography>
|
||||
{r.mitigation && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
||||
→ {r.mitigation}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Box, Divider, LinearProgress, Paper, Typography } from '@mui/material'
|
||||
import type { Match } from '../../domain/match'
|
||||
|
||||
interface BreakdownRowProps {
|
||||
label: string
|
||||
value: number
|
||||
max: number
|
||||
description: string
|
||||
color?: 'success' | 'warning' | 'error' | 'primary'
|
||||
modifier?: boolean
|
||||
}
|
||||
|
||||
function BreakdownRow({ label, value, max, description, color = 'primary', modifier }: BreakdownRowProps) {
|
||||
const pct = Math.round((Math.abs(value) / max) * 100)
|
||||
const isNegative = modifier && value < 0
|
||||
const isPositive = modifier && value > 0
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>{label}</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 700, color: isNegative ? '#c0392b' : isPositive ? '#1a7a4a' : 'text.primary' }}
|
||||
>
|
||||
{modifier && value > 0 ? '+' : ''}{modifier ? value : `${value}/${max}`}
|
||||
</Typography>
|
||||
</Box>
|
||||
{!modifier && (
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={Math.min(pct, 100)}
|
||||
sx={{ height: 8, borderRadius: 4, mb: 0.5 }}
|
||||
color={color}
|
||||
/>
|
||||
)}
|
||||
<Typography variant="caption" color="text.secondary">{description}</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
match: Match
|
||||
}
|
||||
|
||||
export function ScoreBreakdownPanel({ match }: Props) {
|
||||
const sb = match.scoreBreakdown
|
||||
|
||||
const hardColor: 'success' | 'warning' | 'error' =
|
||||
sb.hardMatchScore >= 70 ? 'success' : sb.hardMatchScore >= 50 ? 'warning' : 'error'
|
||||
const softColor: 'success' | 'warning' | 'error' =
|
||||
sb.softFactorScore >= 70 ? 'success' : sb.softFactorScore >= 50 ? 'warning' : 'error'
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2.5, mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 2 }}>Score Breakdown</Typography>
|
||||
|
||||
<BreakdownRow
|
||||
label="Hard-Kriterien (60%)"
|
||||
value={sb.hardMatchScore}
|
||||
max={100}
|
||||
description="Fläche, Standort, Budget, Timing"
|
||||
color={hardColor}
|
||||
/>
|
||||
<BreakdownRow
|
||||
label="Soft Factors (40%)"
|
||||
value={sb.softFactorScore}
|
||||
max={100}
|
||||
description="Prestige, Erreichbarkeit, ESG, Flexibilität"
|
||||
color={softColor}
|
||||
/>
|
||||
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
|
||||
<BreakdownRow
|
||||
label="Datenqualitäts-Modifier"
|
||||
value={sb.dataQualityModifier}
|
||||
max={15}
|
||||
description="Basierend auf Vollständigkeit und Aktualität der Objektdaten"
|
||||
modifier
|
||||
/>
|
||||
<BreakdownRow
|
||||
label="Konfidenz-Modifier"
|
||||
value={sb.confidenceModifier}
|
||||
max={15}
|
||||
description="Basierend auf Quelltyp (Verified/Extern/Signal)"
|
||||
modifier
|
||||
/>
|
||||
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', bgcolor: '#f8fafc', p: 1.5, borderRadius: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Gesamt-Score</Typography>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontWeight: 800,
|
||||
color: sb.totalScore >= 78 ? '#1a7a4a' : sb.totalScore >= 52 ? '#d97706' : '#c0392b',
|
||||
}}
|
||||
>
|
||||
{sb.totalScore}/100
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Box, Chip, Link, Paper, Typography } from '@mui/material'
|
||||
import type { Property } from '../../domain/property'
|
||||
|
||||
const FRESHNESS_META: Record<string, { label: string; color: 'success' | 'warning' | 'error' }> = {
|
||||
FRESH: { label: 'Aktuell (< 48h)', color: 'success' },
|
||||
STALE: { label: 'Veraltet (2–14d)', color: 'warning' },
|
||||
OUTDATED: { label: 'Outdated (> 14d)', color: 'error' },
|
||||
}
|
||||
|
||||
interface Props {
|
||||
property: Property | null
|
||||
}
|
||||
|
||||
export function SourceProvenancePanel({ property }: Props) {
|
||||
if (!property) return null
|
||||
|
||||
const freshness = property.dataQuality?.freshness
|
||||
const freshnessMeta = freshness ? (FRESHNESS_META[freshness] ?? null) : null
|
||||
const sourceUrl = property.sourceUrl ?? property.sourceMeta?.sourceUrl
|
||||
const sourceLabel = property.sourceLabel ?? property.sourceMeta?.sourceLabel ?? property.sourceType ?? '–'
|
||||
const sourceUpdatedAt = property.sourceUpdatedAt ?? property.sourceMeta?.sourceUpdatedAt
|
||||
const lastVerified = property.dataQuality?.lastVerifiedAt
|
||||
|
||||
const rows: { label: string; value: React.ReactNode }[] = [
|
||||
{ label: 'Quelltyp', value: property.sourceType ?? '–' },
|
||||
{
|
||||
label: 'Quelle',
|
||||
value: sourceUrl ? (
|
||||
<Link href={sourceUrl} target="_blank" rel="noopener noreferrer" variant="body2">
|
||||
{sourceLabel}
|
||||
</Link>
|
||||
) : (
|
||||
<Typography variant="body2">{sourceLabel}</Typography>
|
||||
),
|
||||
},
|
||||
...(sourceUpdatedAt ? [{ label: 'Letzte Aktualisierung', value: <Typography variant="body2">{sourceUpdatedAt}</Typography> }] : []),
|
||||
...(lastVerified ? [{ label: 'Letzte Verifikation', value: <Typography variant="body2">{lastVerified}</Typography> }] : []),
|
||||
...(freshnessMeta ? [{
|
||||
label: 'Aktualität',
|
||||
value: <Chip label={freshnessMeta.label} size="small" color={freshnessMeta.color} />,
|
||||
}] : []),
|
||||
]
|
||||
|
||||
const warnings = property.dataQuality?.warnings ?? []
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2.5, mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Quelle & Herkunft</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
{rows.map((row, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 160 }}>
|
||||
{row.label}
|
||||
</Typography>
|
||||
{row.value}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
{warnings.length > 0 && (
|
||||
<Box sx={{ mt: 1.5, p: 1, bgcolor: '#fffbeb', borderRadius: 1 }}>
|
||||
{warnings.map((w, i) => (
|
||||
<Typography key={i} variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
⚠ {w}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Box, Chip, Paper, Typography } from '@mui/material'
|
||||
import { ArrowLeftRight } from 'lucide-react'
|
||||
import type { Match } from '../../domain/match'
|
||||
|
||||
const SEVERITY_META: Record<string, { label: string; color: 'error' | 'warning' | 'default' }> = {
|
||||
HIGH: { label: 'Hoch', color: 'error' },
|
||||
MEDIUM: { label: 'Mittel', color: 'warning' },
|
||||
LOW: { label: 'Gering', color: 'default' },
|
||||
}
|
||||
|
||||
interface Props {
|
||||
match: Match
|
||||
}
|
||||
|
||||
export function TradeoffPanel({ match }: Props) {
|
||||
const tradeoffs = match.tradeoffs ?? []
|
||||
if (tradeoffs.length === 0) return null
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2.5, mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Abwägungen</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{tradeoffs.map((t, i) => {
|
||||
const meta = SEVERITY_META[t.severity] ?? SEVERITY_META.LOW
|
||||
return (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{ p: 1.5, bgcolor: '#fffbeb', border: '1px solid #fde68a', borderRadius: 1 }}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75 }}>
|
||||
<ArrowLeftRight size={15} color="#d97706" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, flex: 1 }}>
|
||||
{t.criterion}
|
||||
</Typography>
|
||||
<Chip label={meta.label} size="small" color={meta.color} variant="outlined" />
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
|
||||
{t.concern}
|
||||
</Typography>
|
||||
{t.mitigation && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>
|
||||
Mitigation: {t.mitigation}
|
||||
</Typography>
|
||||
)}
|
||||
{t.impactOnScore !== undefined && (
|
||||
<Typography variant="caption" sx={{ display: 'block', mt: 0.5, color: '#c0392b' }}>
|
||||
Score-Einfluss: {t.impactOnScore} Punkte
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export { MatchDetailHeader } from './MatchDetailHeader'
|
||||
export { ExecutiveSummaryPanel } from './ExecutiveSummaryPanel'
|
||||
export { PropertyOverviewPanel } from './PropertyOverviewPanel'
|
||||
export { NeedAlignmentPanel } from './NeedAlignmentPanel'
|
||||
export { ScoreBreakdownPanel } from './ScoreBreakdownPanel'
|
||||
export { TradeoffPanel } from './TradeoffPanel'
|
||||
export { RiskPanel } from './RiskPanel'
|
||||
export { MissingInformationPanel } from './MissingInformationPanel'
|
||||
export { SourceProvenancePanel } from './SourceProvenancePanel'
|
||||
export { FutureAvailabilityContextPanel } from './FutureAvailabilityContextPanel'
|
||||
export { NextActionsPanel } from './NextActionsPanel'
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useNavigate } from 'react-router'
|
||||
import { MatchCardCompact } from '../match-card/MatchCardCompact'
|
||||
import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter'
|
||||
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||
@@ -10,6 +11,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function UnifiedResultCard({ result, isInCompare, onCompare }: Props) {
|
||||
const navigate = useNavigate()
|
||||
const isFuture = result.resultType === 'FUTURE_AVAILABILITY'
|
||||
const compareId = !isFuture
|
||||
? (result as { property: { id: string } }).property.id
|
||||
@@ -40,8 +42,7 @@ export function UnifiedResultCard({ result, isInCompare, onCompare }: Props) {
|
||||
label: 'Details',
|
||||
actionType: 'OPEN_DETAIL',
|
||||
variant: 'primary',
|
||||
disabled: true,
|
||||
onClick: () => {},
|
||||
onClick: () => navigate(`/demand/results/${result.matchId}`),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { Match } from '../domain/match'
|
||||
import type { Need } from '../domain/need'
|
||||
import type { Property } from '../domain/property'
|
||||
import type { StrongMatchItem } from '../domain/dashboard'
|
||||
import type { ScoreBreakdown } from '../domain/match'
|
||||
import type { ListResponse, ItemResponse } from './types'
|
||||
import { buildFullMatch, computeRankedMatches } from '../features/matching/rankingEngine'
|
||||
|
||||
@@ -38,6 +39,16 @@ export const matchService = {
|
||||
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||
},
|
||||
|
||||
async getMatchDetail(id: string): Promise<ItemResponse<Match | null>> {
|
||||
const data = await provider.getById(id)
|
||||
return { data }
|
||||
},
|
||||
|
||||
async getScoreBreakdown(matchId: string): Promise<ItemResponse<ScoreBreakdown | null>> {
|
||||
const match = await provider.getById(matchId)
|
||||
return { data: match?.scoreBreakdown ?? null }
|
||||
},
|
||||
|
||||
// ── Engine-based methods ──────────────────────────────────────────────────
|
||||
|
||||
computeMatch(need: Need, property: Property): Match {
|
||||
|
||||
Reference in New Issue
Block a user