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:
@@ -4,6 +4,7 @@ import {
|
||||
Briefcase,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
FileCheck,
|
||||
FileText,
|
||||
Newspaper,
|
||||
@@ -42,6 +43,16 @@ const SIGNAL_TYPE_LABELS: Record<string, string> = {
|
||||
SPACE_CONSOLIDATION: 'Flächenkonsolidierung',
|
||||
}
|
||||
|
||||
// Human-readable explanation of WHY a signal type is relevant to a space search
|
||||
const SIGNAL_RELEVANCE_BRIDGE: Record<string, string> = {
|
||||
EXPANSION: 'Neuer Flächenbedarf entsteht — Fläche wird gesucht',
|
||||
POSSIBLE_MOVE_OUT: 'Fläche könnte frei werden — frühzeitig beobachten',
|
||||
CONSTRUCTION_PROJECT:'Neubau geplant — zukünftige Verfügbarkeit möglich',
|
||||
RESTRUCTURING: 'Flächenveränderung durch Restrukturierung möglich',
|
||||
PROJECT_DEVELOPMENT: 'Projektentwicklung — neue Flächen in Planung',
|
||||
SPACE_CONSOLIDATION: 'Konsolidierung — Teilflächen könnten verfügbar werden',
|
||||
}
|
||||
|
||||
const CREDIBILITY_META: Record<string, { label: string; color: string }> = {
|
||||
HIGH: { label: 'Hohe Quellenqualität', color: '#1a7a4a' },
|
||||
MEDIUM: { label: 'Mittlere Quellenqualität', color: '#d97706' },
|
||||
@@ -54,6 +65,7 @@ function SignalHero({ vm }: { vm: MatchCardViewModel }) {
|
||||
const tier = getScoreTier(vm.matchScore)
|
||||
const theme = SCORE_THEME[tier]
|
||||
const signalLabel = vm.signalType ? (SIGNAL_TYPE_LABELS[vm.signalType] ?? vm.signalType) : 'Signal'
|
||||
const relevanceBridge = vm.signalType ? (SIGNAL_RELEVANCE_BRIDGE[vm.signalType] ?? null) : null
|
||||
const initials = (vm.title ?? '?')
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
@@ -66,7 +78,7 @@ function SignalHero({ vm }: { vm: MatchCardViewModel }) {
|
||||
{/* Gradient hero background */}
|
||||
<Box
|
||||
sx={{
|
||||
height: 130,
|
||||
height: 140,
|
||||
background: 'linear-gradient(135deg, #1a0a3a 0%, #3b0a6e 50%, #6d28d9 100%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -75,6 +87,7 @@ function SignalHero({ vm }: { vm: MatchCardViewModel }) {
|
||||
gap: 0.5,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
px: 7,
|
||||
}}
|
||||
>
|
||||
{/* Subtle pattern overlay */}
|
||||
@@ -86,25 +99,32 @@ function SignalHero({ vm }: { vm: MatchCardViewModel }) {
|
||||
|
||||
{/* Company initials */}
|
||||
<Box sx={{
|
||||
width: 44, height: 44, borderRadius: '50%',
|
||||
width: 40, height: 40, borderRadius: '50%',
|
||||
bgcolor: 'rgba(255,255,255,0.15)',
|
||||
border: '1.5px solid rgba(255,255,255,0.3)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
zIndex: 1,
|
||||
zIndex: 1, flexShrink: 0,
|
||||
}}>
|
||||
<Typography sx={{ color: 'white', fontWeight: 800, fontSize: '1rem', letterSpacing: 1 }}>
|
||||
<Typography sx={{ color: 'white', fontWeight: 800, fontSize: '0.9rem', letterSpacing: 1 }}>
|
||||
{initials}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Signal type label */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, zIndex: 1 }}>
|
||||
<Zap size={11} color="#c4b5fd" />
|
||||
<Typography sx={{ color: '#c4b5fd', fontSize: '0.68rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.8 }}>
|
||||
<Zap size={10} color="#c4b5fd" />
|
||||
<Typography sx={{ color: '#c4b5fd', fontSize: '0.63rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.8 }}>
|
||||
{signalLabel}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Relevance bridge — why this appears in results */}
|
||||
{relevanceBridge && (
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.85)', fontSize: '0.67rem', textAlign: 'center', lineHeight: 1.3, zIndex: 1 }}>
|
||||
{relevanceBridge}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Verified badge */}
|
||||
{vm.signalIsVerified && (
|
||||
<Box sx={{ position: 'absolute', bottom: 8, right: 8, display: 'flex', alignItems: 'center', gap: 0.4, bgcolor: 'rgba(26,122,74,0.9)', borderRadius: 1, px: 0.75, py: 0.25 }}>
|
||||
@@ -153,10 +173,24 @@ function SignalMetaStrip({ vm }: { vm: MatchCardViewModel }) {
|
||||
{/* Source + credibility row */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
|
||||
{sourceMeta && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, bgcolor: '#ede9fe', color: '#6d28d9', borderRadius: 1, px: 0.75, py: 0.25 }}>
|
||||
{sourceMeta.icon}
|
||||
<Typography sx={{ fontSize: '0.68rem', fontWeight: 600 }}>{sourceMeta.label}</Typography>
|
||||
</Box>
|
||||
vm.signalSourceUrl ? (
|
||||
<Box
|
||||
component="a"
|
||||
href={vm.signalSourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 0.4, bgcolor: '#ede9fe', color: '#6d28d9', borderRadius: 1, px: 0.75, py: 0.25, textDecoration: 'none', '&:hover': { bgcolor: '#ddd6fe' } }}
|
||||
>
|
||||
{sourceMeta.icon}
|
||||
<Typography sx={{ fontSize: '0.68rem', fontWeight: 600 }}>{sourceMeta.label}</Typography>
|
||||
<ExternalLink size={9} style={{ opacity: 0.7 }} />
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, bgcolor: '#ede9fe', color: '#6d28d9', borderRadius: 1, px: 0.75, py: 0.25 }}>
|
||||
{sourceMeta.icon}
|
||||
<Typography sx={{ fontSize: '0.68rem', fontWeight: 600 }}>{sourceMeta.label}</Typography>
|
||||
</Box>
|
||||
)
|
||||
)}
|
||||
{credMeta && (
|
||||
<Tooltip title={credMeta.label} arrow>
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface MatchCardViewModel {
|
||||
|
||||
// Schattenmarkt / FUTURE_AVAILABILITY signal fields
|
||||
signalSourceType?: 'PRESS' | 'CONSTRUCTION_PERMIT' | 'JOB_POSTING' | 'COMPANY_REPORT' | 'MARKET_DATA' | 'MANUAL'
|
||||
signalSourceUrl?: string
|
||||
signalSourceCredibility?: 'LOW' | 'MEDIUM' | 'HIGH'
|
||||
signalProbability?: number
|
||||
signalMarketIndicator?: string
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Box, Chip, Divider, Typography } from '@mui/material'
|
||||
import { Alert, Box, Chip, Divider, Typography } from '@mui/material'
|
||||
import { Zap } from 'lucide-react'
|
||||
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||
import { UnifiedResultCard } from './UnifiedResultCard'
|
||||
import { SCORE_THEME } from '../shared/scoreTheme'
|
||||
@@ -72,12 +73,67 @@ function TierHeader({ label, sublabel, tierKey, count, isFirst }: TierHeaderProp
|
||||
)
|
||||
}
|
||||
|
||||
function SchattenmarktSectionHeader({ count }: { count: number }) {
|
||||
return (
|
||||
<Box sx={{ mt: 4, mb: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1.5 }}>
|
||||
<Box sx={{ width: 4, height: 28, borderRadius: 2, background: 'linear-gradient(180deg, #7c3aed, #4c1d95)', flexShrink: 0 }} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Zap size={15} color="#7c3aed" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.2, color: '#4c1d95' }}>
|
||||
Schattenmarkt
|
||||
</Typography>
|
||||
<Chip
|
||||
label={count}
|
||||
size="small"
|
||||
sx={{ fontWeight: 700, fontSize: '0.75rem', bgcolor: '#ede9fe', color: '#6d28d9', border: '1px solid #c4b5fd' }}
|
||||
/>
|
||||
</Box>
|
||||
<Divider sx={{ flex: 1 }} />
|
||||
</Box>
|
||||
<Alert
|
||||
severity="info"
|
||||
icon={<Zap size={16} />}
|
||||
sx={{
|
||||
mb: 2,
|
||||
bgcolor: '#faf5ff',
|
||||
border: '1px solid #e9d5ff',
|
||||
color: '#4c1d95',
|
||||
'& .MuiAlert-icon': { color: '#7c3aed' },
|
||||
}}
|
||||
>
|
||||
<strong>Was sind Schattenmarkt-Signale?</strong> Die KI hat aus öffentlichen Quellen (Stelleninseraten, Pressemeldungen, Baubewilligungen etc.) Hinweise gefunden, dass in Ihrer Zielregion in absehbarer Zeit Flächen frei werden könnten. Dies sind <strong>keine bestätigten Angebote</strong> — aber frühzeitige Hinweise, die Sie im Auge behalten sollten. Klicken Sie auf Details für Quellen und die vollständige KI-Analyse.
|
||||
</Alert>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function UnifiedResultFeed({ results, view = 'list' }: Props) {
|
||||
const tiers = TIER_CONFIG.map(t => ({ ...t, items: results.filter(t.filter) }))
|
||||
const realResults = results.filter(r => r.resultType !== 'FUTURE_AVAILABILITY')
|
||||
const signalResults = results.filter(r => r.resultType === 'FUTURE_AVAILABILITY')
|
||||
|
||||
const tiers = TIER_CONFIG.map(t => ({ ...t, items: realResults.filter(t.filter) }))
|
||||
.filter(t => t.items.length > 0)
|
||||
|
||||
function renderCards(items: UnifiedMatchResult[]) {
|
||||
return view === 'grid' ? (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 2 }}>
|
||||
{items.map(result => (
|
||||
<UnifiedResultCard key={result.matchId} result={result} view="grid" />
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{items.map(result => (
|
||||
<UnifiedResultCard key={result.matchId} result={result} view="list" />
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Real properties — scored tiers */}
|
||||
{tiers.map((tier, idx) => (
|
||||
<Box key={tier.key}>
|
||||
<TierHeader
|
||||
@@ -87,21 +143,17 @@ export function UnifiedResultFeed({ results, view = 'list' }: Props) {
|
||||
count={tier.items.length}
|
||||
isFirst={idx === 0}
|
||||
/>
|
||||
{view === 'grid' ? (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 2 }}>
|
||||
{tier.items.map(result => (
|
||||
<UnifiedResultCard key={result.matchId} result={result} view="grid" />
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{tier.items.map(result => (
|
||||
<UnifiedResultCard key={result.matchId} result={result} view="list" />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{renderCards(tier.items)}
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{/* Schattenmarkt signals — dedicated section */}
|
||||
{signalResults.length > 0 && (
|
||||
<Box>
|
||||
<SchattenmarktSectionHeader count={signalResults.length} />
|
||||
{renderCards(signalResults)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ export function buildMatchCardViewModel(
|
||||
isStaleData: false,
|
||||
// Schattenmarkt signal fields
|
||||
signalSourceType: signal?.source?.type,
|
||||
signalSourceUrl: signal?.source?.url,
|
||||
signalSourceCredibility: signal?.source?.credibility,
|
||||
signalProbability: signal?.probability,
|
||||
signalMarketIndicator: signal?.marketIndicator,
|
||||
|
||||
Reference in New Issue
Block a user