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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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 (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<AddToShortlistDialog />
|
||||
<ResultFeedHeader
|
||||
total={sorted.length}
|
||||
@@ -82,7 +88,29 @@ export default function Results() {
|
||||
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 && (
|
||||
<Card sx={{ bgcolor: '#eff6ff', p: 2, mb: 2, border: '1px solid #bfdbfe' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2 }}>
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const [filters, setFilters] = useState<PropertyTableFilters>({})
|
||||
|
||||
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 (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<PageHeader
|
||||
title="Objektverwaltung"
|
||||
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} />
|
||||
|
||||
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||||
|
||||
Reference in New Issue
Block a user