feat: Decision Intelligence simplification pass

Phase 1 — MatchDetail: collapse from 11 panels to 3 visible sections
(Warum dieser Match? / Nächste Aktion / toggle). All analysis panels
(ScoreBreakdown, Risk, MissingData, NeedAlignment, Location, Tradeoffs,
FutureAvailabilityContext) hidden behind "Vollständige Analyse anzeigen".
Removes 2-column sidebar layout for cleaner single-column reading flow.

Phase 2 — Supply Dashboard: replace data quality KPIs with demand
intelligence. Hero now shows "Starke Match-Anfragen", active properties,
and Zukunftssignale count. Top 4 matches listed with score + reason +
next action. Data quality demoted to secondary collapsed notice.

Phase 3 — Future Availability: add informational banner above the results
feed when Zukunftssignale are present. Framed as professional market
intelligence (contract expiries, construction signals) not as risk.
Removed Zukunftssignal from DecisionContextPanel risks array.

Phase 4 — Naming: standardise RESULT_TYPE_META to "Verifiziertes Objekt" /
"Externes Angebot" / "Maison Work" / "Zukunftssignal" across all screens.
EXTERNAL_MARKET gets distinct amber color (#d97706). Remove Gold/Silver/
Bronze tier label from match score badge — only the % number is shown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-24 17:09:12 +02:00
parent 58a0a05eff
commit 99c26b5464
5 changed files with 265 additions and 128 deletions
+145 -38
View File
@@ -1,15 +1,11 @@
import { Box, Button, Typography } from '@mui/material'
import { Box, Button, Chip, Divider, Paper, Typography } from '@mui/material'
import { ArrowRight, Building2, TrendingUp, Zap } from 'lucide-react'
import { useNavigate } from 'react-router'
import { useSupplyDashboard } from '../../hooks/useSupplyDashboard'
import { useSessionStore } from '../../stores/sessionStore'
import { UserRole } from '../../domain/enums'
import {
DashboardHeader,
KpiGrid,
ReviewTaskWidget,
DashboardSkeleton,
} from '../../components/supply'
import { DashboardHeader, ReviewTaskWidget, DashboardSkeleton } from '../../components/supply'
import { DS_BG, DS_BORDER, DS_SURFACE, DS_TEXT } from '../../lib/ds'
export default function SupplyDashboard() {
const { data, isLoading, isError, refetch } = useSupplyDashboard()
@@ -18,22 +14,15 @@ export default function SupplyDashboard() {
const role = currentUser?.role ?? UserRole.DEMAND_USER
const isReviewer = role === UserRole.REVIEWER
const isOwnerViewer = role === UserRole.OWNER_VIEWER
if (isLoading) return <DashboardSkeleton />
if (isError) {
return (
<Box sx={{ p: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<Typography variant="h6" color="error">
Dashboard konnte nicht geladen werden
</Typography>
<Typography variant="body2" color="text.secondary">
Der Service ist vorübergehend nicht verfügbar.
</Typography>
<Button variant="outlined" onClick={() => refetch()}>
Erneut versuchen
</Button>
<Typography variant="h6" color="error">Dashboard konnte nicht geladen werden</Typography>
<Typography variant="body2" color="text.secondary">Der Service ist vorübergehend nicht verfügbar.</Typography>
<Button variant="outlined" onClick={() => refetch()}>Erneut versuchen</Button>
</Box>
)
}
@@ -43,43 +32,161 @@ export default function SupplyDashboard() {
<Box sx={{ p: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<Typography variant="h6">Noch keine Objekte vorhanden</Typography>
<Typography variant="body2" color="text.secondary">
Fügen Sie Ihr erstes Objekt hinzu oder laden Sie Demo-Daten.
Fügen Sie Ihr erstes Objekt hinzu wir matchen es sofort mit aktiven Suchanfragen.
</Typography>
<Button variant="contained" onClick={() => navigate('/supply/properties')}>
Objekte verwalten
Erstes Objekt erfassen
</Button>
</Box>
)
}
const strongMatchCount = data.strongMatchCount ?? 0
const topMatches = data.strongMatches ?? []
const futureSignalCount = data.futureSignals?.total ?? 0
return (
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 3 }}>
<DashboardHeader
orgName={currentUser?.organizationName}
lastUpdated={data.lastUpdated}
/>
<DashboardHeader orgName={currentUser?.organizationName} lastUpdated={data.lastUpdated} />
{isOwnerViewer ? (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 2 }}>
<Box sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
<Typography variant="overline" color="text.secondary">
Aktive Objekte
{/* ── Hero: Nachfrage-Intelligence ── */}
<Paper sx={{ p: 0, overflow: 'hidden', border: `1px solid ${DS_BORDER.default}` }}>
{/* KPI strip */}
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr 1fr', md: 'repeat(3, 1fr)' }, divideX: true }}>
{/* Starke Matches */}
<Box sx={{ p: 2.5, borderRight: `1px solid ${DS_BORDER.default}`, bgcolor: strongMatchCount > 0 ? DS_SURFACE.success.bg : 'white' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.5 }}>
<TrendingUp size={15} color={strongMatchCount > 0 ? DS_TEXT.success : DS_TEXT.muted} />
<Typography variant="overline" sx={{ color: strongMatchCount > 0 ? DS_TEXT.success : DS_TEXT.muted, fontWeight: 700, lineHeight: 1 }}>
Starke Match-Anfragen
</Typography>
</Box>
<Typography variant="h3" sx={{ fontWeight: 800, color: strongMatchCount > 0 ? DS_TEXT.success : DS_TEXT.disabled, lineHeight: 1 }}>
{strongMatchCount}
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700 }}>
{data.activeProperties} / {data.totalProperties}
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
qualifizierte Interessenten 80%
</Typography>
</Box>
<Box sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
<Typography variant="overline" color="text.secondary">
Ø Datenqualität
{/* Aktive Objekte */}
<Box sx={{ p: 2.5, borderRight: `1px solid ${DS_BORDER.default}`, cursor: 'pointer', '&:hover': { bgcolor: DS_BG.subtle } }}
onClick={() => navigate('/supply/properties')}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.5 }}>
<Building2 size={15} color={DS_TEXT.muted} />
<Typography variant="overline" sx={{ color: DS_TEXT.muted, fontWeight: 700, lineHeight: 1 }}>
Aktive Objekte
</Typography>
</Box>
<Typography variant="h3" sx={{ fontWeight: 800, color: DS_TEXT.primary, lineHeight: 1 }}>
{data.activeProperties}
<Typography component="span" variant="h5" sx={{ color: DS_TEXT.muted, fontWeight: 400, ml: 0.5 }}>
/ {data.totalProperties}
</Typography>
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700 }}>
{data.avgDataQuality}%
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>verfügbar · im Bestand</Typography>
</Box>
{/* Zukunftssignale */}
<Box sx={{ p: 2.5, bgcolor: futureSignalCount > 0 ? '#faf5ff' : 'white' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.5 }}>
<Zap size={15} color={futureSignalCount > 0 ? '#7c3aed' : DS_TEXT.muted} />
<Typography variant="overline" sx={{ color: futureSignalCount > 0 ? '#7c3aed' : DS_TEXT.muted, fontWeight: 700, lineHeight: 1 }}>
Zukunftssignale
</Typography>
</Box>
<Typography variant="h3" sx={{ fontWeight: 800, color: futureSignalCount > 0 ? '#7c3aed' : DS_TEXT.disabled, lineHeight: 1 }}>
{futureSignalCount}
</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
vorqualifizierte Nachfrage-Signale
</Typography>
</Box>
</Box>
) : (
<KpiGrid data={data} />
{/* ── Top matches list ── */}
{topMatches.length > 0 && (
<>
<Divider />
<Box sx={{ px: 2.5, py: 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_TEXT.muted, textTransform: 'uppercase', letterSpacing: 0.6 }}>
Stärkste Interessenten
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
{topMatches.slice(0, 4).map((m, i) => (
<Box
key={m.matchId}
sx={{
display: 'flex', alignItems: 'center', gap: 2,
px: 2.5, py: 1.25,
borderTop: i > 0 ? `1px solid ${DS_BORDER.muted}` : undefined,
cursor: 'pointer',
'&:hover': { bgcolor: DS_BG.subtle },
}}
onClick={() => navigate(`/demand/match/${m.matchId}`)}
>
<Box sx={{
minWidth: 44, height: 36, borderRadius: 1,
bgcolor: m.matchScore >= 80 ? DS_SURFACE.success.bg : DS_SURFACE.warning.bg,
border: `1px solid ${m.matchScore >= 80 ? DS_SURFACE.success.border : DS_SURFACE.warning.border}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<Typography sx={{ fontWeight: 800, fontSize: '0.9rem', color: m.matchScore >= 80 ? DS_TEXT.success : DS_TEXT.warning }}>
{m.matchScore}%
</Typography>
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }} noWrap>{m.propertyTitle}</Typography>
<Typography variant="caption" color="text.secondary" noWrap>{m.topReason}</Typography>
</Box>
<Chip
label={m.nextBestAction}
size="small"
sx={{ fontSize: '0.65rem', height: 20, bgcolor: DS_BG.subtle, color: DS_TEXT.secondary, flexShrink: 0, maxWidth: 140 }}
/>
<ArrowRight size={14} color={DS_TEXT.disabled} style={{ flexShrink: 0 }} />
</Box>
))}
</Box>
<Divider />
<Box sx={{ px: 2.5, py: 1.25 }}>
<Button
size="small"
variant="text"
onClick={() => navigate('/demand/results')}
sx={{ textTransform: 'none', color: DS_TEXT.secondary, fontWeight: 500 }}
>
Alle Matches anzeigen
</Button>
</Box>
</>
)}
</Paper>
{/* ── Datenpflege (sekundär, collapsed) ── */}
{(data.dataQuality?.critical ?? 0) > 0 && (
<Paper sx={{ p: 2, border: `1px solid ${DS_BORDER.default}`, bgcolor: DS_BG.subtle }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_TEXT.secondary }}>
{data.dataQuality!.critical} Objekte mit Datenlücken
</Typography>
<Typography variant="caption" color="text.secondary">
Vollständige Daten verbessern die Match-Qualität erheblich.
</Typography>
</Box>
<Button
size="small"
variant="outlined"
onClick={() => navigate('/supply/properties')}
sx={{ textTransform: 'none', flexShrink: 0 }}
>
Datenpflege
</Button>
</Box>
</Paper>
)}
{isReviewer && data.reviewTasks !== null && (