feat: Schattenmarkt UX — separate section, relevance bridge, source links

Signals are now in their own feed section with an explanation banner
so users understand they are not confirmed properties. Each signal card
shows WHY it appears in their search (e.g. 'Fläche könnte frei werden').
Source pills are now clickable links. FutureAvailabilityContextPanel
redesigned with full AI summary, source button, and relevance bridge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-18 21:59:08 +02:00
parent b5cdb0353b
commit 9cf3e72033
5 changed files with 281 additions and 70 deletions
@@ -1,4 +1,15 @@
import { Alert, Box, Chip, Paper, Typography } from '@mui/material'
import { Alert, Box, Button, Chip, Divider, Paper, Typography } from '@mui/material'
import {
BarChart2,
Briefcase,
ExternalLink,
FileCheck,
FileText,
Newspaper,
ShieldCheck,
User,
Zap,
} from 'lucide-react'
import type { Match } from '../../domain/match'
import type { FutureSignal } from '../../domain/futureSignal'
@@ -11,20 +22,36 @@ const SIGNAL_TYPE_LABELS: Record<string, string> = {
SPACE_CONSOLIDATION: 'Flächenkonsolidierung',
}
const SIGNAL_RELEVANCE_BRIDGE: Record<string, string> = {
EXPANSION: 'Ein Unternehmen sucht neue Flächen — Ihr Angebot könnte gefragt sein.',
POSSIBLE_MOVE_OUT: 'Dieses Unternehmen könnte seinen Standort aufgeben — die Fläche wird für Sie verfügbar.',
CONSTRUCTION_PROJECT:'Ein Neubau entsteht — neue Flächen könnten zur Vermietung angeboten werden.',
RESTRUCTURING: 'Restrukturierung deutet auf Flächenänderungen hin.',
PROJECT_DEVELOPMENT: 'Projektentwicklung könnte neue Gewerbeflächen schaffen.',
SPACE_CONSOLIDATION: 'Konsolidierung — Teilflächen könnten frei werden.',
}
const SOURCE_META: Record<string, { label: string; icon: React.ReactNode }> = {
JOB_POSTING: { label: 'Stelleninserate', icon: <Briefcase size={13} /> },
PRESS: { label: 'Pressebericht', icon: <Newspaper size={13} /> },
CONSTRUCTION_PERMIT: { label: 'Baubewilligung', icon: <FileCheck size={13} /> },
COMPANY_REPORT: { label: 'Geschäftsbericht', icon: <FileText size={13} /> },
MARKET_DATA: { label: 'Marktdaten', icon: <BarChart2 size={13} /> },
MANUAL: { label: 'Analyst', icon: <User size={13} /> },
}
const CREDIBILITY_META: Record<string, { label: string; color: string }> = {
HIGH: { label: 'Hohe Quellenqualität', color: '#1a7a4a' },
MEDIUM: { label: 'Mittlere Quellenqualität', color: '#d97706' },
LOW: { label: 'Niedrige Quellenqualität', color: '#c0392b' },
}
const SENSITIVITY_META: Record<string, { label: string; color: 'error' | 'warning' | 'default' }> = {
CONFIDENTIAL: { label: 'Vertraulich', color: 'error' },
INTERNAL: { label: 'Intern', color: 'warning' },
PUBLIC: { label: 'Öffentlich', color: 'default' },
}
const REVIEW_STATUS_META: Record<string, { label: string; color: 'warning' | 'success' | 'default' | 'error' }> = {
UNREVIEWED: { label: 'Ungeprüft', color: 'warning' },
IN_REVIEW: { label: 'In Prüfung', color: 'warning' },
APPROVED: { label: 'Genehmigt', color: 'success' },
REJECTED: { label: 'Abgelehnt', color: 'error' },
FLAGGED: { label: 'Markiert', color: 'warning' },
}
interface Props {
match: Match
signal: FutureSignal | null
@@ -33,63 +60,159 @@ interface Props {
export function FutureAvailabilityContextPanel({ match: _match, signal }: Props) {
if (!signal) return null
const sourceMeta = SOURCE_META[signal.source.type] ?? null
const credMeta = CREDIBILITY_META[signal.source.credibility] ?? null
const sensitiveMeta = SENSITIVITY_META[signal.sensitivityLevel] ?? SENSITIVITY_META.PUBLIC
const reviewMeta = signal.reviewStatus ? (REVIEW_STATUS_META[signal.reviewStatus] ?? null) : null
const relevanceBridge = signal.signalType ? (SIGNAL_RELEVANCE_BRIDGE[signal.signalType] ?? null) : null
const probPct = Math.round(signal.probability * 100)
const confPct = Math.round(signal.confidenceScore * 100)
return (
<Paper sx={{ p: 2.5, mb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Zukunftssignal Kontext</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Zap size={16} color="#7c3aed" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Schattenmarkt-Signal</Typography>
{signal.isVerified && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, bgcolor: '#f0fdf4', border: '1px solid #86efac', borderRadius: 1, px: 0.75, py: 0.2 }}>
<ShieldCheck size={11} color="#1a7a4a" />
<Typography sx={{ fontSize: '0.68rem', color: '#1a7a4a', fontWeight: 700 }}>Verifiziert</Typography>
</Box>
)}
</Box>
{/* Mandatory disclaimer */}
<Alert severity="warning" sx={{ mb: 2 }}>
<Alert severity="warning" sx={{ mb: 2, fontSize: '0.8rem' }}>
{signal.disclaimer}
</Alert>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{/* Relevance bridge */}
{relevanceBridge && (
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, bgcolor: '#faf5ff', border: '1px solid #e9d5ff', borderRadius: 1, px: 1.5, py: 1.25, mb: 2 }}>
<Zap size={14} color="#7c3aed" style={{ flexShrink: 0, marginTop: 2 }} />
<Box>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: '#4c1d95', mb: 0.25 }}>Warum erscheint dieses Signal in Ihrer Suche?</Typography>
<Typography sx={{ fontSize: '0.8rem', color: '#4c1d95', lineHeight: 1.5 }}>{relevanceBridge}</Typography>
</Box>
</Box>
)}
{/* Key metrics row */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5, mb: 2 }}>
{signal.signalType && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 160 }}>Signaltyp</Typography>
<Typography variant="body2">{SIGNAL_TYPE_LABELS[signal.signalType] ?? signal.signalType}</Typography>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Signaltyp</Typography>
<Chip label={SIGNAL_TYPE_LABELS[signal.signalType] ?? signal.signalType} size="small" sx={{ bgcolor: '#ede9fe', color: '#6d28d9', fontWeight: 600 }} />
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 160 }}>Konfidenz</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{Math.round(signal.confidenceScore * 100)}%
</Typography>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Wahrscheinlichkeit</Typography>
<Typography variant="body2" sx={{ fontWeight: 700, color: probPct >= 70 ? '#1a7a4a' : probPct >= 50 ? '#d97706' : '#c0392b' }}>{probPct}%</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 160 }}>Wahrscheinlichkeit</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{Math.round(signal.probability * 100)}%
</Typography>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Konfidenz</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{confPct}%</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 160 }}>Zeithorizont</Typography>
<Typography variant="body2">~{signal.timeHorizonMonths} Monate</Typography>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Zeithorizont</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>~{signal.timeHorizonMonths} Monate</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 160 }}>Vertraulichkeit</Typography>
{signal.areaSqmEstimate && (
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Flächenschätzung</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>~{signal.areaSqmEstimate.toLocaleString('de-CH')} m²</Typography>
</Box>
)}
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Vertraulichkeit</Typography>
<Chip label={sensitiveMeta.label} size="small" color={sensitiveMeta.color} variant="outlined" />
</Box>
{reviewMeta && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 160 }}>Prüfstatus</Typography>
<Chip label={reviewMeta.label} size="small" color={reviewMeta.color} />
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 160 }}>Quellglaubwürdigkeit</Typography>
<Typography variant="body2">{signal.source.credibility}</Typography>
</Box>
</Box>
<Divider sx={{ mb: 2 }} />
{/* AI Summary */}
{signal.aiSummary && (
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1 }}>
<Zap size={14} color="#7c3aed" />
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#4c1d95' }}>KI-Analyse</Typography>
</Box>
<Box sx={{ bgcolor: '#faf5ff', border: '1px solid #e9d5ff', borderRadius: 1, p: 1.5 }}>
<Typography variant="body2" sx={{ color: '#1e1b4b', lineHeight: 1.65 }}>
{signal.aiSummary}
</Typography>
</Box>
</Box>
)}
{/* Evidence */}
{signal.evidence?.summary && (
<Box sx={{ mt: 1.5, p: 1.5, bgcolor: '#f8fafc', borderRadius: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
Evidenz
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.75 }}>Evidenz</Typography>
<Box sx={{ bgcolor: '#f8fafc', borderRadius: 1, p: 1.5 }}>
<Typography variant="body2" color="text.secondary">{signal.evidence.summary}</Typography>
</Box>
</Box>
)}
<Divider sx={{ mb: 2 }} />
{/* Source section */}
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1 }}>Quelle</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
{sourceMeta && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: '#ede9fe', color: '#6d28d9', borderRadius: 1, px: 1, py: 0.5 }}>
{sourceMeta.icon}
<Typography sx={{ fontSize: '0.8rem', fontWeight: 600 }}>{sourceMeta.label}</Typography>
</Box>
)}
{credMeta && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: credMeta.color }} />
<Typography variant="body2" sx={{ color: credMeta.color, fontWeight: 600 }}>{credMeta.label}</Typography>
</Box>
)}
{signal.source.publishedAt && (
<Typography variant="caption" color="text.secondary">
Publiziert: {new Date(signal.source.publishedAt).toLocaleDateString('de-CH', { day: '2-digit', month: 'long', year: 'numeric' })}
</Typography>
<Typography variant="body2" color="text.secondary">{signal.evidence.summary}</Typography>
)}
{signal.source.url && (
<Button
component="a"
href={signal.source.url}
target="_blank"
rel="noopener noreferrer"
size="small"
variant="outlined"
startIcon={<ExternalLink size={13} />}
sx={{ textTransform: 'none', color: '#7c3aed', borderColor: '#c4b5fd', '&:hover': { borderColor: '#7c3aed', bgcolor: '#faf5ff' } }}
>
Originalquelle öffnen
</Button>
)}
</Box>
{signal.evidence?.sourceUrls && signal.evidence.sourceUrls.length > 0 && (
<Box sx={{ mt: 1.5 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
Weitere Belege ({signal.evidence.sourceUrls.length})
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{signal.evidence.sourceUrls.map((url, i) => (
<Box
key={i}
component="a"
href={url}
target="_blank"
rel="noopener noreferrer"
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#7c3aed', fontSize: '0.75rem', textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}
>
<ExternalLink size={11} />
<Typography variant="caption" sx={{ color: 'inherit' }}>{url}</Typography>
</Box>
))}
</Box>
</Box>
)}
</Paper>