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:
@@ -63,11 +63,6 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
|||||||
<Typography sx={{ fontWeight: 900, fontSize: '1.5rem', color: theme.text, lineHeight: 1 }}>
|
<Typography sx={{ fontWeight: 900, fontSize: '1.5rem', color: theme.text, lineHeight: 1 }}>
|
||||||
{vm.matchScore}%
|
{vm.matchScore}%
|
||||||
</Typography>
|
</Typography>
|
||||||
{tier !== 'bronze' && (
|
|
||||||
<Typography sx={{ fontSize: '0.575rem', color: theme.text, opacity: 0.8, textTransform: 'uppercase', letterSpacing: 0.8, lineHeight: 1.2 }}>
|
|
||||||
{theme.label}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ position: 'absolute', top: 10, right: 44, display: 'flex', flexDirection: 'column', gap: 0.5, alignItems: 'flex-end' }}>
|
<Box sx={{ position: 'absolute', top: 10, right: 44, display: 'flex', flexDirection: 'column', gap: 0.5, alignItems: 'flex-end' }}>
|
||||||
<Chip label={rt.label} size="small" sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} />
|
<Chip label={rt.label} size="small" sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} />
|
||||||
|
|||||||
+2
-2
@@ -198,8 +198,8 @@ export function scoreToDataQualityLevel(score: number): DataQualityLevel {
|
|||||||
// ── Result type meta — single source for labels + colors ─────────────────────
|
// ── Result type meta — single source for labels + colors ─────────────────────
|
||||||
|
|
||||||
export const RESULT_TYPE_META: Record<string, { label: string; color: string; bg: string }> = {
|
export const RESULT_TYPE_META: Record<string, { label: string; color: string; bg: string }> = {
|
||||||
VERIFIED_PORTFOLIO: { label: 'Plattform', color: '#1e3a5f', bg: 'rgba(30,58,95,0.10)' },
|
VERIFIED_PORTFOLIO: { label: 'Verifiziertes Objekt', color: '#1e3a5f', bg: 'rgba(30,58,95,0.10)' },
|
||||||
EXTERNAL_MARKET: { label: 'Plattform', color: '#1e3a5f', bg: 'rgba(30,58,95,0.10)' },
|
EXTERNAL_MARKET: { label: 'Externes Angebot', color: '#d97706', bg: 'rgba(217,119,6,0.10)' },
|
||||||
MAISON_WORK: { label: 'Maison Work', color: '#0369a1', bg: 'rgba(3,105,161,0.10)' },
|
MAISON_WORK: { label: 'Maison Work', color: '#0369a1', bg: 'rgba(3,105,161,0.10)' },
|
||||||
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed', bg: 'rgba(109,40,217,0.10)' },
|
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed', bg: 'rgba(109,40,217,0.10)' },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { Box, Button, Chip, CircularProgress, Paper, Typography } from '@mui/material'
|
import { Box, Button, Chip, CircularProgress, Paper, Typography } from '@mui/material'
|
||||||
import { ArrowLeft } from 'lucide-react'
|
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
import { useNavigate, useParams } from 'react-router'
|
import { useNavigate, useParams } from 'react-router'
|
||||||
import { useCompareStore } from '../../stores/compareStore'
|
import { useCompareStore } from '../../stores/compareStore'
|
||||||
import { usePipelineStore } from '../../stores/pipelineStore'
|
import { usePipelineStore } from '../../stores/pipelineStore'
|
||||||
@@ -23,18 +24,12 @@ import { useMatchDetailData } from '../../hooks/useMatchDetailData'
|
|||||||
import { useMatchDetail } from '../../hooks/useMatches'
|
import { useMatchDetail } from '../../hooks/useMatches'
|
||||||
import { MatchDetailHero } from '../../components/match-detail/MatchDetailHero'
|
import { MatchDetailHero } from '../../components/match-detail/MatchDetailHero'
|
||||||
import { MatchDetailPropertySections } from '../../components/match-detail/MatchDetailPropertySections'
|
import { MatchDetailPropertySections } from '../../components/match-detail/MatchDetailPropertySections'
|
||||||
|
import { RESULT_TYPE_META, DS_TEXT, DS_SURFACE, DS_BORDER, DS_BG } from '../../lib/ds'
|
||||||
|
|
||||||
// ── Match helpers ──────────────────────────────────────────────────────────────
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
|
const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
|
||||||
|
|
||||||
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
|
|
||||||
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
|
|
||||||
EXTERNAL_MARKET: { label: 'Direktinserat', color: '#d97706' },
|
|
||||||
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
|
|
||||||
FUTURE_AVAILABILITY: { label: 'Future Availability', color: '#7c3aed' },
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildReasons(match: NonNullable<ReturnType<typeof useMatchDetail>['data']>): MatchCardReason[] {
|
function buildReasons(match: NonNullable<ReturnType<typeof useMatchDetail>['data']>): MatchCardReason[] {
|
||||||
return match.positiveFactors.slice(0, 3).map(f => ({
|
return match.positiveFactors.slice(0, 3).map(f => ({
|
||||||
type: (HARD_CRITERIA.has(f.criterion) ? 'HARD_FACT' : 'SOFT_FACTOR') as MatchCardReason['type'],
|
type: (HARD_CRITERIA.has(f.criterion) ? 'HARD_FACT' : 'SOFT_FACTOR') as MatchCardReason['type'],
|
||||||
@@ -44,12 +39,14 @@ function buildReasons(match: NonNullable<ReturnType<typeof useMatchDetail>['data
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── page ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function MatchDetail() {
|
export default function MatchDetail() {
|
||||||
const { matchId } = useParams<{ matchId: string }>()
|
const { matchId } = useParams<{ matchId: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { addToCompare } = useCompareStore()
|
const { addToCompare } = useCompareStore()
|
||||||
const { openSavedDialog } = usePipelineStore()
|
const { openSavedDialog } = usePipelineStore()
|
||||||
|
const [showFullAnalysis, setShowFullAnalysis] = useState(false)
|
||||||
|
|
||||||
const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(matchId ?? '')
|
const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(matchId ?? '')
|
||||||
|
|
||||||
@@ -79,12 +76,15 @@ export default function MatchDetail() {
|
|||||||
const location = city
|
const location = city
|
||||||
? `${city}${property?.location?.district ? `, ${property.location.district}` : ''}`
|
? `${city}${property?.location?.district ? `, ${property.location.district}` : ''}`
|
||||||
: signal?.locationHint ?? '–'
|
: signal?.locationHint ?? '–'
|
||||||
// Tax calculator — only shown when AI evaluated taxEnvironment
|
|
||||||
const allFactors = [...match.positiveFactors, ...(match.negativeFactors ?? [])]
|
const allFactors = [...match.positiveFactors, ...(match.negativeFactors ?? [])]
|
||||||
const hasTaxFactor = allFactors.some(f => f.criterion === 'taxEnvironment')
|
const hasTaxFactor = allFactors.some(f => f.criterion === 'taxEnvironment')
|
||||||
const cityIntel = city ? getCityIntelligence(city) : null
|
const cityIntel = city ? getCityIntelligence(city) : null
|
||||||
const taxCalculatorUrl = hasTaxFactor ? (cityIntel?.taxCalculatorUrl ?? undefined) : undefined
|
const taxCalculatorUrl = hasTaxFactor ? (cityIntel?.taxCalculatorUrl ?? undefined) : undefined
|
||||||
|
|
||||||
|
const topTradeoff = match.tradeoffs?.[0] ?? match.tradeOffs?.[0]
|
||||||
|
const summary = match.explainabilitySummary || `${match.matchStrength}-Match mit ${match.matchScore} Punkten.`
|
||||||
|
|
||||||
const handleCompare = () => {
|
const handleCompare = () => {
|
||||||
if (match && !isFuture && property) {
|
if (match && !isFuture && property) {
|
||||||
addToCompare({
|
addToCompare({
|
||||||
@@ -112,7 +112,6 @@ export default function MatchDetail() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Key facts for the strip below the hero
|
|
||||||
const keyFacts = isFuture ? [
|
const keyFacts = isFuture ? [
|
||||||
{ label: 'Flächenschätzung', value: signal?.areaSqmEstimate ? `~${signal.areaSqmEstimate.toLocaleString('de-CH')} m²` : '–' },
|
{ label: 'Flächenschätzung', value: signal?.areaSqmEstimate ? `~${signal.areaSqmEstimate.toLocaleString('de-CH')} m²` : '–' },
|
||||||
{ label: 'Zeithorizont', value: signal?.timeHorizonMonths ? `~${signal.timeHorizonMonths} Monate` : '–' },
|
{ label: 'Zeithorizont', value: signal?.timeHorizonMonths ? `~${signal.timeHorizonMonths} Monate` : '–' },
|
||||||
@@ -125,12 +124,12 @@ export default function MatchDetail() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ bgcolor: '#f1f5f9', minHeight: '100vh' }}>
|
<Box sx={{ bgcolor: DS_BG.page, minHeight: '100vh' }}>
|
||||||
<AddToPipelineDialog />
|
<AddToPipelineDialog />
|
||||||
|
|
||||||
{/* Sticky back nav */}
|
{/* Sticky back nav */}
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
px: 3, py: 1.25, bgcolor: 'white', borderBottom: '1px solid #e2e8f0',
|
px: 3, py: 1.25, bgcolor: 'white', borderBottom: `1px solid ${DS_BORDER.default}`,
|
||||||
position: 'sticky', top: 0, zIndex: 100,
|
position: 'sticky', top: 0, zIndex: 100,
|
||||||
display: 'flex', alignItems: 'center', gap: 2,
|
display: 'flex', alignItems: 'center', gap: 2,
|
||||||
}}>
|
}}>
|
||||||
@@ -138,12 +137,16 @@ export default function MatchDetail() {
|
|||||||
startIcon={<ArrowLeft size={15} />}
|
startIcon={<ArrowLeft size={15} />}
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ color: '#64748b', fontWeight: 500, textTransform: 'none' }}
|
sx={{ color: DS_TEXT.muted, fontWeight: 500, textTransform: 'none' }}
|
||||||
>
|
>
|
||||||
Zurück zu Resultaten
|
Zurück zu Resultaten
|
||||||
</Button>
|
</Button>
|
||||||
{isFuture && (
|
{isFuture && (
|
||||||
<Chip label="Future Availability Signal" size="small" sx={{ bgcolor: '#faf5ff', color: '#7c3aed', fontWeight: 600 }} />
|
<Chip
|
||||||
|
label="Zukunftssignal"
|
||||||
|
size="small"
|
||||||
|
sx={{ bgcolor: '#faf5ff', color: '#7c3aed', fontWeight: 600 }}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -160,41 +163,66 @@ export default function MatchDetail() {
|
|||||||
onShortlist={handleShortlist}
|
onShortlist={handleShortlist}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* ── Main content ── */}
|
||||||
<Box sx={{ px: { xs: 1.5, sm: 3 }, py: { xs: 2, sm: 3 }, display: 'flex', flexDirection: { xs: 'column', lg: 'row' }, gap: 3, alignItems: 'flex-start' }}>
|
<Box sx={{ maxWidth: 780, mx: 'auto', px: { xs: 1.5, sm: 3 }, py: { xs: 2, sm: 3 }, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
|
||||||
{/* Main column */}
|
{/* ── Section A: Warum dieser Match? ── */}
|
||||||
<Box sx={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
||||||
<ExecutiveSummaryPanel match={match} />
|
|
||||||
<NeedAlignmentPanel match={match} need={need} property={property} />
|
|
||||||
|
|
||||||
{/* ── Property Details ── */}
|
|
||||||
{!isFuture && property && <MatchDetailPropertySections property={property} match={match} />}
|
|
||||||
|
|
||||||
{reasons.length > 0 && (
|
|
||||||
<Paper sx={{ p: 2.5 }}>
|
<Paper sx={{ p: 2.5 }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Warum dieses Match</Typography>
|
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.25 }}>Warum dieser Match?</Typography>
|
||||||
<MatchReasonList reasons={reasons} maxItems={3} />
|
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mb: reasons.length > 0 ? 1.5 : 0, lineHeight: 1.65 }}>
|
||||||
{match.negativeFactors.length > 0 && (
|
{summary}
|
||||||
<Box sx={{ mt: 1.5 }}>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block', mb: 0.75 }}>
|
|
||||||
Schwächere Faktoren
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
{reasons.length > 0 && <MatchReasonList reasons={reasons} maxItems={3} />}
|
||||||
{match.negativeFactors.slice(0, 3).map((f, i) => (
|
{topTradeoff && (
|
||||||
<Typography key={i} variant="body2" color="text.secondary">
|
<Box sx={{
|
||||||
· {f.criterion}: {f.explanation}
|
mt: 1.5, display: 'flex', gap: 0.875, alignItems: 'flex-start',
|
||||||
|
bgcolor: DS_SURFACE.warning.bg, border: `1px solid ${DS_SURFACE.warning.border}`,
|
||||||
|
borderRadius: 1.5, px: 1.5, py: 1,
|
||||||
|
}}>
|
||||||
|
<AlertTriangle size={14} color={DS_TEXT.warning} style={{ marginTop: 3, flexShrink: 0 }} />
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_TEXT.warning, display: 'block', mb: 0.25 }}>
|
||||||
|
Hauptabwägung
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{topTradeoff.concern}
|
||||||
</Typography>
|
</Typography>
|
||||||
))}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
)}
|
|
||||||
|
|
||||||
|
{/* ── Section B: Nächste Aktion ── */}
|
||||||
|
<NextActionsPanel
|
||||||
|
match={match}
|
||||||
|
onCompare={handleCompare}
|
||||||
|
onShortlist={handleShortlist}
|
||||||
|
onReview={() => {}}
|
||||||
|
onReject={() => {}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Full analysis toggle ── */}
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => setShowFullAnalysis(v => !v)}
|
||||||
|
fullWidth
|
||||||
|
endIcon={showFullAnalysis ? <ChevronUp size={15} /> : <ChevronDown size={15} />}
|
||||||
|
sx={{
|
||||||
|
borderStyle: 'dashed', color: DS_TEXT.muted, borderColor: DS_BORDER.strong,
|
||||||
|
textTransform: 'none', fontWeight: 500,
|
||||||
|
'&:hover': { borderColor: DS_TEXT.muted, bgcolor: DS_BG.subtle },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showFullAnalysis ? 'Weniger anzeigen' : 'Vollständige Analyse anzeigen'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* ── Full analysis (hidden by default) ── */}
|
||||||
|
{showFullAnalysis && (
|
||||||
|
<>
|
||||||
|
<ExecutiveSummaryPanel match={match} />
|
||||||
|
<NeedAlignmentPanel match={match} need={need} property={property} />
|
||||||
|
{!isFuture && property && <MatchDetailPropertySections property={property} match={match} />}
|
||||||
<LocationIntelligencePanel property={property} />
|
<LocationIntelligencePanel property={property} />
|
||||||
|
|
||||||
{/* Map — always show when image was the hero above */}
|
|
||||||
{!isFuture && property?.images?.[0] && property?.location?.coordinates && (
|
{!isFuture && property?.images?.[0] && property?.location?.coordinates && (
|
||||||
<Paper sx={{ overflow: 'hidden', p: 0 }}>
|
<Paper sx={{ overflow: 'hidden', p: 0 }}>
|
||||||
<Box sx={{ px: 2.5, pt: 2, pb: 1 }}>
|
<Box sx={{ px: 2.5, pt: 2, pb: 1 }}>
|
||||||
@@ -211,25 +239,13 @@ export default function MatchDetail() {
|
|||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TradeoffPanel match={match} />
|
<TradeoffPanel match={match} />
|
||||||
<RiskPanel match={match} />
|
<RiskPanel match={match} />
|
||||||
<MissingInformationPanel match={match} />
|
<MissingInformationPanel match={match} />
|
||||||
|
|
||||||
{isFuture && <FutureAvailabilityContextPanel match={match} signal={signal} />}
|
{isFuture && <FutureAvailabilityContextPanel match={match} signal={signal} />}
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Sidebar */}
|
|
||||||
<Box sx={{ width: { xs: '100%', lg: 320 }, flexShrink: 0, position: { xs: 'static', lg: 'sticky' }, top: 64, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
||||||
<ScoreBreakdownPanel match={match} taxCalculatorUrl={taxCalculatorUrl} isFuture={isFuture} signal={signal} />
|
<ScoreBreakdownPanel match={match} taxCalculatorUrl={taxCalculatorUrl} isFuture={isFuture} signal={signal} />
|
||||||
<NextActionsPanel
|
</>
|
||||||
match={match}
|
)}
|
||||||
onCompare={handleCompare}
|
|
||||||
onShortlist={handleShortlist}
|
|
||||||
onReview={() => {}}
|
|
||||||
onReject={() => {}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useMemo, useEffect } from 'react'
|
import { useState, useMemo, useEffect } from 'react'
|
||||||
import { Box, Button, Card, Typography } from '@mui/material'
|
import { Box, Button, Card, Typography } from '@mui/material'
|
||||||
|
import { Zap } from 'lucide-react'
|
||||||
import { useNavigate, useLocation } from 'react-router'
|
import { useNavigate, useLocation } from 'react-router'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { useUnifiedResults } from '../../hooks/useUnifiedResults'
|
import { useUnifiedResults } from '../../hooks/useUnifiedResults'
|
||||||
@@ -117,11 +118,10 @@ export default function Results() {
|
|||||||
metrics={[
|
metrics={[
|
||||||
...(strongCount > 0 ? [{ label: 'starke Treffer (≥80)', value: strongCount, severity: 'positive' as const }] : []),
|
...(strongCount > 0 ? [{ label: 'starke Treffer (≥80)', value: strongCount, severity: 'positive' as const }] : []),
|
||||||
...(maisonWorkCount > 0 ? [{ label: 'Maison Work', value: maisonWorkCount, severity: 'neutral' as const }] : []),
|
...(maisonWorkCount > 0 ? [{ label: 'Maison Work', value: maisonWorkCount, severity: 'neutral' as const }] : []),
|
||||||
...(futureCount > 0 ? [{ label: 'Zukunftssignale', value: futureCount, severity: 'warning' as const }] : []),
|
...(futureCount > 0 ? [{ label: 'Zukunftssignale', value: futureCount, severity: 'neutral' as const }] : []),
|
||||||
...(missingDataCount > 0 ? [{ label: 'mit Datenlücken', value: missingDataCount, severity: 'warning' as const }] : []),
|
...(missingDataCount > 0 ? [{ label: 'mit Datenlücken', value: missingDataCount, severity: 'warning' as const }] : []),
|
||||||
]}
|
]}
|
||||||
risks={[
|
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`] : []),
|
...(missingDataCount > 0 ? [`${missingDataCount} Treffer mit fehlenden Daten — Einschätzung eingeschränkt`] : []),
|
||||||
]}
|
]}
|
||||||
actions={[
|
actions={[
|
||||||
@@ -197,7 +197,26 @@ export default function Results() {
|
|||||||
) : sorted.length === 0 ? (
|
) : sorted.length === 0 ? (
|
||||||
<FeedEmptyState filtered={filterSource !== 'ALL' || !showFutureAvailability || !showOwnProperties} />
|
<FeedEmptyState filtered={filterSource !== 'ALL' || !showFutureAvailability || !showOwnProperties} />
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
{futureCount > 0 && showFutureAvailability && (
|
||||||
|
<Box sx={{
|
||||||
|
mb: 2, px: 2, py: 1.375,
|
||||||
|
bgcolor: '#faf5ff', border: '1px solid #e9d5ff', borderLeft: '3px solid #7c3aed',
|
||||||
|
borderRadius: 1.5, display: 'flex', alignItems: 'flex-start', gap: 1.25,
|
||||||
|
}}>
|
||||||
|
<Zap size={15} color="#7c3aed" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||||
|
<Box>
|
||||||
|
<Typography sx={{ fontSize: '0.8rem', fontWeight: 700, color: '#5b21b6', mb: 0.2 }}>
|
||||||
|
{futureCount} Zukunftssignal{futureCount > 1 ? 'e' : ''} in diesen Ergebnissen
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ fontSize: '0.75rem', color: '#6d28d9', lineHeight: 1.5 }}>
|
||||||
|
Noch nicht öffentlich verfügbare Flächen — gewonnen aus Mietvertrags-Auslaufzeiten, Bauprojekten und Marktbewegungen. Frühere Markttransparenz für Ihre Suche.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
<UnifiedResultFeed results={sorted} view={view} />
|
<UnifiedResultFeed results={sorted} view={view} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -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 { useNavigate } from 'react-router'
|
||||||
import { useSupplyDashboard } from '../../hooks/useSupplyDashboard'
|
import { useSupplyDashboard } from '../../hooks/useSupplyDashboard'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import { UserRole } from '../../domain/enums'
|
import { UserRole } from '../../domain/enums'
|
||||||
import {
|
import { DashboardHeader, ReviewTaskWidget, DashboardSkeleton } from '../../components/supply'
|
||||||
DashboardHeader,
|
import { DS_BG, DS_BORDER, DS_SURFACE, DS_TEXT } from '../../lib/ds'
|
||||||
KpiGrid,
|
|
||||||
ReviewTaskWidget,
|
|
||||||
DashboardSkeleton,
|
|
||||||
} from '../../components/supply'
|
|
||||||
|
|
||||||
|
|
||||||
export default function SupplyDashboard() {
|
export default function SupplyDashboard() {
|
||||||
const { data, isLoading, isError, refetch } = useSupplyDashboard()
|
const { data, isLoading, isError, refetch } = useSupplyDashboard()
|
||||||
@@ -18,22 +14,15 @@ export default function SupplyDashboard() {
|
|||||||
|
|
||||||
const role = currentUser?.role ?? UserRole.DEMAND_USER
|
const role = currentUser?.role ?? UserRole.DEMAND_USER
|
||||||
const isReviewer = role === UserRole.REVIEWER
|
const isReviewer = role === UserRole.REVIEWER
|
||||||
const isOwnerViewer = role === UserRole.OWNER_VIEWER
|
|
||||||
|
|
||||||
if (isLoading) return <DashboardSkeleton />
|
if (isLoading) return <DashboardSkeleton />
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
<Box sx={{ p: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||||
<Typography variant="h6" color="error">
|
<Typography variant="h6" color="error">Dashboard konnte nicht geladen werden</Typography>
|
||||||
Dashboard konnte nicht geladen werden
|
<Typography variant="body2" color="text.secondary">Der Service ist vorübergehend nicht verfügbar.</Typography>
|
||||||
</Typography>
|
<Button variant="outlined" onClick={() => refetch()}>Erneut versuchen</Button>
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Der Service ist vorübergehend nicht verfügbar.
|
|
||||||
</Typography>
|
|
||||||
<Button variant="outlined" onClick={() => refetch()}>
|
|
||||||
Erneut versuchen
|
|
||||||
</Button>
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -43,43 +32,161 @@ export default function SupplyDashboard() {
|
|||||||
<Box sx={{ p: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
<Box sx={{ p: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||||
<Typography variant="h6">Noch keine Objekte vorhanden</Typography>
|
<Typography variant="h6">Noch keine Objekte vorhanden</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<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>
|
</Typography>
|
||||||
<Button variant="contained" onClick={() => navigate('/supply/properties')}>
|
<Button variant="contained" onClick={() => navigate('/supply/properties')}>
|
||||||
Objekte verwalten
|
Erstes Objekt erfassen
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const strongMatchCount = data.strongMatchCount ?? 0
|
||||||
|
const topMatches = data.strongMatches ?? []
|
||||||
|
const futureSignalCount = data.futureSignals?.total ?? 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
<DashboardHeader
|
<DashboardHeader orgName={currentUser?.organizationName} lastUpdated={data.lastUpdated} />
|
||||||
orgName={currentUser?.organizationName}
|
|
||||||
lastUpdated={data.lastUpdated}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isOwnerViewer ? (
|
{/* ── Hero: Nachfrage-Intelligence ── */}
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 2 }}>
|
<Paper sx={{ p: 0, overflow: 'hidden', border: `1px solid ${DS_BORDER.default}` }}>
|
||||||
<Box sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
|
|
||||||
<Typography variant="overline" color="text.secondary">
|
{/* 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="caption" sx={{ color: DS_TEXT.muted }}>
|
||||||
|
qualifizierte Interessenten ≥ 80%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 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
|
Aktive Objekte
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h4" sx={{ fontWeight: 700 }}>
|
</Box>
|
||||||
{data.activeProperties} / {data.totalProperties}
|
<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="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>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
|
<Typography variant="h3" sx={{ fontWeight: 800, color: futureSignalCount > 0 ? '#7c3aed' : DS_TEXT.disabled, lineHeight: 1 }}>
|
||||||
<Typography variant="overline" color="text.secondary">
|
{futureSignalCount}
|
||||||
Ø Datenqualität
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h4" sx={{ fontWeight: 700 }}>
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||||
{data.avgDataQuality}%
|
vorqualifizierte Nachfrage-Signale
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</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 && (
|
{isReviewer && data.reviewTasks !== null && (
|
||||||
|
|||||||
Reference in New Issue
Block a user