From 9e7a09f8a258aaa896672d464c1aa74a32212233 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Sun, 17 May 2026 13:56:12 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20F027=20screen-level=20decision=20design?= =?UTF-8?q?=20=E2=80=94=20DecisionContextPanel=20+=20Properties=20+=20Resu?= =?UTF-8?q?lts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/ui/DecisionContextPanel.tsx | 158 +++++++++++++++++++++ src/components/ui/index.ts | 2 + src/pages/demand/Results.tsx | 32 ++++- src/pages/supply/Properties.tsx | 63 ++++++++ 4 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 src/components/ui/DecisionContextPanel.tsx diff --git a/src/components/ui/DecisionContextPanel.tsx b/src/components/ui/DecisionContextPanel.tsx new file mode 100644 index 0000000..aa37e53 --- /dev/null +++ b/src/components/ui/DecisionContextPanel.tsx @@ -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, string> = { + neutral: '#f1f5f9', + positive: '#f0fdf4', + warning: '#fef9c3', + critical: '#fef2f2', +} + +const SEVERITY_TEXT: Record, 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 ( + + {/* Main row */} + + {/* Decision question */} + + + + + {decision} + + {context && ( + + {context} + + )} + + + + {/* Metric chips */} + {metrics.length > 0 && ( + + {metrics.map((m, i) => { + const sev = m.severity ?? 'neutral' + return ( + + ) + })} + + )} + + {/* Actions + expand toggle */} + + {actions.map((a, i) => ( + + ))} + {hasDetails && ( + setExpanded(v => !v)} + sx={{ color: '#64748b', width: 24, height: 24 }} + > + {expanded ? : } + + )} + + + + {/* Expandable details */} + + + {risks.length > 0 && ( + } sx={{ py: 0.25, '& .MuiAlert-message': { fontSize: '0.75rem' } }}> + Risiken:{' '}{risks.join(' · ')} + + )} + {missing.length > 0 && ( + + Fehlende Daten:{' '}{missing.join(' · ')} + + )} + {children} + + + + ) +} diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index d4ee06c..9173e00 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -10,3 +10,5 @@ export { UnauthorizedState } from './UnauthorizedState' export { RestrictedState } from './RestrictedState' export { ToastProvider } from './ToastProvider' export { ConfirmDialog } from './ConfirmDialog' +export { DecisionContextPanel } from './DecisionContextPanel' +export type { DecisionMetric, DecisionAction } from './DecisionContextPanel' diff --git a/src/pages/demand/Results.tsx b/src/pages/demand/Results.tsx index 7380259..35a6a9a 100644 --- a/src/pages/demand/Results.tsx +++ b/src/pages/demand/Results.tsx @@ -4,6 +4,7 @@ import { useNavigate, useLocation } from 'react-router' import { useQuery, useQueryClient } from '@tanstack/react-query' import { useUnifiedResults } from '../../hooks/useUnifiedResults' import { needService } from '../../services/needService' +import { DecisionContextPanel } from '../../components/ui' import { FeedEmptyState, FeedSkeleton, @@ -71,9 +72,14 @@ export default function Results() { const verifiedCount = results.filter(r => r.resultType === 'VERIFIED_PORTFOLIO').length const externalCount = results.filter(r => r.resultType === 'EXTERNAL_MARKET').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 ( - + - + {!isLoading && results.length > 0 && ( + 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') }, + ]} + /> + )} + + {activeNeed && ( diff --git a/src/pages/supply/Properties.tsx b/src/pages/supply/Properties.tsx index 909a22c..cdc805b 100644 --- a/src/pages/supply/Properties.tsx +++ b/src/pages/supply/Properties.tsx @@ -1,6 +1,8 @@ import { useState } from 'react' import { Box, Drawer } from '@mui/material' +import { useNavigate } from 'react-router' import { PageHeader } from '../../components/layout' +import { DecisionContextPanel } from '../../components/ui' import { useProperties } from '../../hooks/useProperties' import { PropertyFilterBar, PropertyTable, PropertyDetailView } from '../../components/supply' import type { PropertyTableFilters } from '../../components/supply' @@ -45,18 +47,79 @@ function applyFilters(properties: Property[], filters: PropertyTableFilters): Pr } export default function Properties() { + const navigate = useNavigate() const [selectedId, setSelectedId] = useState(null) const [filters, setFilters] = useState({}) const { data: properties = [], isLoading, isError } = useProperties() 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 ( + + {!isLoading && properties.length > 0 && ( + 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'), + }, + ]} + /> + )} +