feat: F027 screen-level decision design — DecisionContextPanel + Properties + Results

- Add DecisionContextPanel (src/components/ui): compact decision-framing
  strip with question, metric chips, expandable risks/missing data, actions.
  Answers: Welche Entscheidung? Welche Daten helfen/fehlen? Welche Risiken?
  Welche nächste Aktion?

- Properties.tsx: replace generic listing header with decision frame:
  "Welche Objekte sind matchbereit und wo blockieren Datenlücken Matches?"
  Surfaces: matchbereit count, critical gaps, low-confidence count, stale
  data, missing field names, risk statements. Primary CTA → Datenpflege.

- Results.tsx: add decision frame above result feed:
  "Welche Treffer lohnen sich für die Shortlist — welche tragen Risiken?"
  Surfaces: strong-match count, result-type breakdown, data-gap count,
  future-signal probabilistic risk warning. CTAs → Compare, refine search.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-17 13:56:12 +02:00
parent 4429a7890a
commit 9e7a09f8a2
4 changed files with 253 additions and 2 deletions
+158
View File
@@ -0,0 +1,158 @@
import { useState } from 'react'
import { Alert, Box, Button, Chip, Collapse, IconButton, Typography } from '@mui/material'
import { AlertTriangle, ChevronDown, ChevronUp, Target } from 'lucide-react'
import type { ReactNode } from 'react'
export interface DecisionMetric {
label: string
value: string | number
severity?: 'neutral' | 'positive' | 'warning' | 'critical'
}
export interface DecisionAction {
label: string
primary?: boolean
onClick: () => void
}
interface Props {
/** The core question this screen answers */
decision: string
/** One-line explanation of why this decision matters */
context?: string
/** Key data points relevant to the decision */
metrics?: DecisionMetric[]
/** Missing data that could affect the decision */
missing?: string[]
/** Active risks the user should be aware of */
risks?: string[]
/** Available actions — first primary action is highlighted */
actions?: DecisionAction[]
/** Custom content after the standard rows */
children?: ReactNode
}
const SEVERITY_COLOR: Record<NonNullable<DecisionMetric['severity']>, string> = {
neutral: '#f1f5f9',
positive: '#f0fdf4',
warning: '#fef9c3',
critical: '#fef2f2',
}
const SEVERITY_TEXT: Record<NonNullable<DecisionMetric['severity']>, string> = {
neutral: '#475569',
positive: '#1a7a4a',
warning: '#92400e',
critical: '#991b1b',
}
export function DecisionContextPanel({
decision,
context,
metrics = [],
missing = [],
risks = [],
actions = [],
children,
}: Props) {
const [expanded, setExpanded] = useState(false)
const hasDetails = missing.length > 0 || risks.length > 0 || !!children
return (
<Box
sx={{
bgcolor: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
borderLeft: '3px solid #1e3a5f',
px: 2.5,
py: 1.25,
flexShrink: 0,
}}
>
{/* Main row */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2, flexWrap: 'wrap' }}>
{/* Decision question */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, flex: 1, minWidth: 200 }}>
<Target size={15} color="#1e3a5f" style={{ marginTop: 2, flexShrink: 0 }} />
<Box>
<Typography variant="body2" sx={{ fontWeight: 700, fontSize: '0.8125rem', color: '#1e293b', lineHeight: 1.3 }}>
{decision}
</Typography>
{context && (
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', mt: 0.25 }}>
{context}
</Typography>
)}
</Box>
</Box>
{/* Metric chips */}
{metrics.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', alignItems: 'center' }}>
{metrics.map((m, i) => {
const sev = m.severity ?? 'neutral'
return (
<Chip
key={i}
label={`${m.value} ${m.label}`}
size="small"
sx={{
bgcolor: SEVERITY_COLOR[sev],
color: SEVERITY_TEXT[sev],
fontWeight: sev !== 'neutral' ? 700 : 400,
fontSize: '0.7rem',
height: 22,
}}
/>
)
})}
</Box>
)}
{/* Actions + expand toggle */}
<Box sx={{ display: 'flex', gap: 0.75, alignItems: 'center', flexShrink: 0 }}>
{actions.map((a, i) => (
<Button
key={i}
size="small"
variant={a.primary ? 'contained' : 'outlined'}
onClick={a.onClick}
sx={a.primary
? { bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none', fontSize: '0.75rem', py: 0.375, px: 1.25 }
: { textTransform: 'none', fontSize: '0.75rem', py: 0.375, px: 1.25 }
}
>
{a.label}
</Button>
))}
{hasDetails && (
<IconButton
size="small"
onClick={() => setExpanded(v => !v)}
sx={{ color: '#64748b', width: 24, height: 24 }}
>
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</IconButton>
)}
</Box>
</Box>
{/* Expandable details */}
<Collapse in={expanded}>
<Box sx={{ mt: 1.25, display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{risks.length > 0 && (
<Alert severity="warning" icon={<AlertTriangle size={14} />} sx={{ py: 0.25, '& .MuiAlert-message': { fontSize: '0.75rem' } }}>
<strong>Risiken:</strong>{' '}{risks.join(' · ')}
</Alert>
)}
{missing.length > 0 && (
<Alert severity="info" sx={{ py: 0.25, '& .MuiAlert-message': { fontSize: '0.75rem' } }}>
<strong>Fehlende Daten:</strong>{' '}{missing.join(' · ')}
</Alert>
)}
{children}
</Box>
</Collapse>
</Box>
)
}
+2
View File
@@ -10,3 +10,5 @@ export { UnauthorizedState } from './UnauthorizedState'
export { RestrictedState } from './RestrictedState' export { RestrictedState } from './RestrictedState'
export { ToastProvider } from './ToastProvider' export { ToastProvider } from './ToastProvider'
export { ConfirmDialog } from './ConfirmDialog' export { ConfirmDialog } from './ConfirmDialog'
export { DecisionContextPanel } from './DecisionContextPanel'
export type { DecisionMetric, DecisionAction } from './DecisionContextPanel'
+30 -2
View File
@@ -4,6 +4,7 @@ import { useNavigate, useLocation } from 'react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useUnifiedResults } from '../../hooks/useUnifiedResults' import { useUnifiedResults } from '../../hooks/useUnifiedResults'
import { needService } from '../../services/needService' import { needService } from '../../services/needService'
import { DecisionContextPanel } from '../../components/ui'
import { import {
FeedEmptyState, FeedEmptyState,
FeedSkeleton, FeedSkeleton,
@@ -71,9 +72,14 @@ export default function Results() {
const verifiedCount = results.filter(r => r.resultType === 'VERIFIED_PORTFOLIO').length const verifiedCount = results.filter(r => r.resultType === 'VERIFIED_PORTFOLIO').length
const externalCount = results.filter(r => r.resultType === 'EXTERNAL_MARKET').length const externalCount = results.filter(r => r.resultType === 'EXTERNAL_MARKET').length
const futureCount = results.filter(r => r.resultType === 'FUTURE_AVAILABILITY').length const futureCount = results.filter(r => r.resultType === 'FUTURE_AVAILABILITY').length
const strongCount = results.filter(r => r.matchScore >= 80).length
const missingDataCount = results.filter(r =>
'match' in r && Array.isArray((r as { match?: { missingData?: unknown[] } }).match?.missingData) &&
((r as { match?: { missingData?: unknown[] } }).match?.missingData?.length ?? 0) > 0
).length
return ( return (
<Box> <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<AddToShortlistDialog /> <AddToShortlistDialog />
<ResultFeedHeader <ResultFeedHeader
total={sorted.length} total={sorted.length}
@@ -82,7 +88,29 @@ export default function Results() {
futureCount={futureCount} futureCount={futureCount}
/> />
<Box sx={{ px: 3, py: 3 }}> {!isLoading && results.length > 0 && (
<DecisionContextPanel
decision="Welche Treffer lohnen sich für die Shortlist — und welche tragen Risiken, die zuerst geprüft werden müssen?"
context={activeNeed ? `Suche: ${activeNeed.assetType} · ${activeNeed.requiredArea.min}${activeNeed.requiredArea.max} m² · ${activeNeed.preferredLocations.join(', ')}` : undefined}
metrics={[
...(strongCount > 0 ? [{ label: 'starke Treffer (≥80)', value: strongCount, severity: 'positive' as const }] : []),
...(verifiedCount > 0 ? [{ label: 'verifiziertes Portfolio', value: verifiedCount, severity: 'neutral' as const }] : []),
...(externalCount > 0 ? [{ label: 'externer Markt', value: externalCount, severity: 'neutral' as const }] : []),
...(futureCount > 0 ? [{ label: 'Zukunftssignale', value: futureCount, severity: 'warning' as const }] : []),
...(missingDataCount > 0 ? [{ label: 'mit Datenlücken', value: missingDataCount, severity: 'warning' as const }] : []),
]}
risks={[
...(futureCount > 0 ? [`${futureCount} Zukunftssignal${futureCount > 1 ? 'e' : ''} sind probabilistisch — keine bestätigte Verfügbarkeit`] : []),
...(missingDataCount > 0 ? [`${missingDataCount} Treffer mit fehlenden Daten — Einschätzung eingeschränkt`] : []),
]}
actions={[
{ label: 'Vergleich öffnen', onClick: () => navigate('/demand/compare') },
{ label: 'Suche anpassen', onClick: () => navigate('/demand/ai-search') },
]}
/>
)}
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
{activeNeed && ( {activeNeed && (
<Card sx={{ bgcolor: '#eff6ff', p: 2, mb: 2, border: '1px solid #bfdbfe' }}> <Card sx={{ bgcolor: '#eff6ff', p: 2, mb: 2, border: '1px solid #bfdbfe' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2 }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2 }}>
+63
View File
@@ -1,6 +1,8 @@
import { useState } from 'react' import { useState } from 'react'
import { Box, Drawer } from '@mui/material' import { Box, Drawer } from '@mui/material'
import { useNavigate } from 'react-router'
import { PageHeader } from '../../components/layout' import { PageHeader } from '../../components/layout'
import { DecisionContextPanel } from '../../components/ui'
import { useProperties } from '../../hooks/useProperties' import { useProperties } from '../../hooks/useProperties'
import { PropertyFilterBar, PropertyTable, PropertyDetailView } from '../../components/supply' import { PropertyFilterBar, PropertyTable, PropertyDetailView } from '../../components/supply'
import type { PropertyTableFilters } from '../../components/supply' import type { PropertyTableFilters } from '../../components/supply'
@@ -45,18 +47,79 @@ function applyFilters(properties: Property[], filters: PropertyTableFilters): Pr
} }
export default function Properties() { export default function Properties() {
const navigate = useNavigate()
const [selectedId, setSelectedId] = useState<string | null>(null) const [selectedId, setSelectedId] = useState<string | null>(null)
const [filters, setFilters] = useState<PropertyTableFilters>({}) const [filters, setFilters] = useState<PropertyTableFilters>({})
const { data: properties = [], isLoading, isError } = useProperties() const { data: properties = [], isLoading, isError } = useProperties()
const filtered = applyFilters(properties, filters) const filtered = applyFilters(properties, filters)
// Decision-relevant aggregates
const matchReady = properties.filter(
p => (p.availabilityStatus === 'AVAILABLE_NOW' || p.availabilityStatus === 'AVAILABLE_SOON') &&
p.confidenceScore >= 0.7 && p.dataQuality.missingCriticalFields.length === 0
)
const criticalGaps = properties.filter(p => p.dataQuality.missingCriticalFields.length > 0)
const lowConfidence = properties.filter(p => p.confidenceScore < 0.55)
const staleOrOutdated = properties.filter(
p => p.dataQuality.freshness === 'STALE' || p.dataQuality.freshness === 'OUTDATED'
)
// Unique missing fields across all objects
const allMissingFields = [...new Set(
properties.flatMap(p => p.dataQuality.missingCriticalFields)
)].slice(0, 4)
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}> <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<PageHeader <PageHeader
title="Objektverwaltung" title="Objektverwaltung"
subtitle={`${filtered.length} von ${properties.length} Objekten`} subtitle={`${filtered.length} von ${properties.length} Objekten`}
/> />
{!isLoading && properties.length > 0 && (
<DecisionContextPanel
decision="Welche Objekte sind matchbereit — und wo blockieren Datenlücken Matches?"
context="Objekte mit fehlenden Pflichtfeldern oder Konfidenz < 55% erscheinen im Match-Center nicht oder mit niedrigem Rang."
metrics={[
{ label: 'matchbereit', value: matchReady.length, severity: matchReady.length > 0 ? 'positive' : 'warning' },
...(criticalGaps.length > 0
? [{ label: 'kritische Datenlücken', value: criticalGaps.length, severity: 'critical' as const }]
: []
),
...(lowConfidence.length > 0
? [{ label: 'Konfidenz < 55%', value: lowConfidence.length, severity: 'warning' as const }]
: []
),
...(staleOrOutdated.length > 0
? [{ label: 'veraltete Daten', value: staleOrOutdated.length, severity: 'warning' as const }]
: []
),
]}
missing={allMissingFields.length > 0
? [`Fehlende Pflichtfelder bei ${criticalGaps.length} Objekten: ${allMissingFields.join(', ')}`]
: []
}
risks={[
...(criticalGaps.length > 0
? [`${criticalGaps.length} Objekte werden potenziellen Mietern nicht angezeigt`]
: []
),
...(staleOrOutdated.length > 0
? [`${staleOrOutdated.length} Objekte mit veralteten Preisen oder Verfügbarkeiten`]
: []
),
]}
actions={[
{
label: 'Datenpflege starten',
primary: criticalGaps.length > 0 || staleOrOutdated.length > 0,
onClick: () => navigate('/supply/data-quality'),
},
]}
/>
)}
<PropertyFilterBar filters={filters} onFiltersChange={setFilters} /> <PropertyFilterBar filters={filters} onFiltersChange={setFilters} />
<Box sx={{ flex: 1, overflowY: 'auto' }}> <Box sx={{ flex: 1, overflowY: 'auto' }}>