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,
|
Briefcase,
|
||||||
Building2,
|
Building2,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
ExternalLink,
|
||||||
FileCheck,
|
FileCheck,
|
||||||
FileText,
|
FileText,
|
||||||
Newspaper,
|
Newspaper,
|
||||||
@@ -42,6 +43,16 @@ const SIGNAL_TYPE_LABELS: Record<string, string> = {
|
|||||||
SPACE_CONSOLIDATION: 'Flächenkonsolidierung',
|
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 }> = {
|
const CREDIBILITY_META: Record<string, { label: string; color: string }> = {
|
||||||
HIGH: { label: 'Hohe Quellenqualität', color: '#1a7a4a' },
|
HIGH: { label: 'Hohe Quellenqualität', color: '#1a7a4a' },
|
||||||
MEDIUM: { label: 'Mittlere Quellenqualität', color: '#d97706' },
|
MEDIUM: { label: 'Mittlere Quellenqualität', color: '#d97706' },
|
||||||
@@ -54,6 +65,7 @@ function SignalHero({ vm }: { vm: MatchCardViewModel }) {
|
|||||||
const tier = getScoreTier(vm.matchScore)
|
const tier = getScoreTier(vm.matchScore)
|
||||||
const theme = SCORE_THEME[tier]
|
const theme = SCORE_THEME[tier]
|
||||||
const signalLabel = vm.signalType ? (SIGNAL_TYPE_LABELS[vm.signalType] ?? vm.signalType) : 'Signal'
|
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 ?? '?')
|
const initials = (vm.title ?? '?')
|
||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
@@ -66,7 +78,7 @@ function SignalHero({ vm }: { vm: MatchCardViewModel }) {
|
|||||||
{/* Gradient hero background */}
|
{/* Gradient hero background */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
height: 130,
|
height: 140,
|
||||||
background: 'linear-gradient(135deg, #1a0a3a 0%, #3b0a6e 50%, #6d28d9 100%)',
|
background: 'linear-gradient(135deg, #1a0a3a 0%, #3b0a6e 50%, #6d28d9 100%)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@@ -75,6 +87,7 @@ function SignalHero({ vm }: { vm: MatchCardViewModel }) {
|
|||||||
gap: 0.5,
|
gap: 0.5,
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
px: 7,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Subtle pattern overlay */}
|
{/* Subtle pattern overlay */}
|
||||||
@@ -86,25 +99,32 @@ function SignalHero({ vm }: { vm: MatchCardViewModel }) {
|
|||||||
|
|
||||||
{/* Company initials */}
|
{/* Company initials */}
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
width: 44, height: 44, borderRadius: '50%',
|
width: 40, height: 40, borderRadius: '50%',
|
||||||
bgcolor: 'rgba(255,255,255,0.15)',
|
bgcolor: 'rgba(255,255,255,0.15)',
|
||||||
border: '1.5px solid rgba(255,255,255,0.3)',
|
border: '1.5px solid rgba(255,255,255,0.3)',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
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}
|
{initials}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Signal type label */}
|
{/* Signal type label */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, zIndex: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, zIndex: 1 }}>
|
||||||
<Zap size={11} color="#c4b5fd" />
|
<Zap size={10} color="#c4b5fd" />
|
||||||
<Typography sx={{ color: '#c4b5fd', fontSize: '0.68rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.8 }}>
|
<Typography sx={{ color: '#c4b5fd', fontSize: '0.63rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.8 }}>
|
||||||
{signalLabel}
|
{signalLabel}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</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 */}
|
{/* Verified badge */}
|
||||||
{vm.signalIsVerified && (
|
{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 }}>
|
<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 */}
|
{/* Source + credibility row */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
|
||||||
{sourceMeta && (
|
{sourceMeta && (
|
||||||
|
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 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, bgcolor: '#ede9fe', color: '#6d28d9', borderRadius: 1, px: 0.75, py: 0.25 }}>
|
||||||
{sourceMeta.icon}
|
{sourceMeta.icon}
|
||||||
<Typography sx={{ fontSize: '0.68rem', fontWeight: 600 }}>{sourceMeta.label}</Typography>
|
<Typography sx={{ fontSize: '0.68rem', fontWeight: 600 }}>{sourceMeta.label}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
{credMeta && (
|
{credMeta && (
|
||||||
<Tooltip title={credMeta.label} arrow>
|
<Tooltip title={credMeta.label} arrow>
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export interface MatchCardViewModel {
|
|||||||
|
|
||||||
// Schattenmarkt / FUTURE_AVAILABILITY signal fields
|
// Schattenmarkt / FUTURE_AVAILABILITY signal fields
|
||||||
signalSourceType?: 'PRESS' | 'CONSTRUCTION_PERMIT' | 'JOB_POSTING' | 'COMPANY_REPORT' | 'MARKET_DATA' | 'MANUAL'
|
signalSourceType?: 'PRESS' | 'CONSTRUCTION_PERMIT' | 'JOB_POSTING' | 'COMPANY_REPORT' | 'MARKET_DATA' | 'MANUAL'
|
||||||
|
signalSourceUrl?: string
|
||||||
signalSourceCredibility?: 'LOW' | 'MEDIUM' | 'HIGH'
|
signalSourceCredibility?: 'LOW' | 'MEDIUM' | 'HIGH'
|
||||||
signalProbability?: number
|
signalProbability?: number
|
||||||
signalMarketIndicator?: string
|
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 { Match } from '../../domain/match'
|
||||||
import type { FutureSignal } from '../../domain/futureSignal'
|
import type { FutureSignal } from '../../domain/futureSignal'
|
||||||
|
|
||||||
@@ -11,20 +22,36 @@ const SIGNAL_TYPE_LABELS: Record<string, string> = {
|
|||||||
SPACE_CONSOLIDATION: 'Flächenkonsolidierung',
|
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' }> = {
|
const SENSITIVITY_META: Record<string, { label: string; color: 'error' | 'warning' | 'default' }> = {
|
||||||
CONFIDENTIAL: { label: 'Vertraulich', color: 'error' },
|
CONFIDENTIAL: { label: 'Vertraulich', color: 'error' },
|
||||||
INTERNAL: { label: 'Intern', color: 'warning' },
|
INTERNAL: { label: 'Intern', color: 'warning' },
|
||||||
PUBLIC: { label: 'Öffentlich', color: 'default' },
|
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 {
|
interface Props {
|
||||||
match: Match
|
match: Match
|
||||||
signal: FutureSignal | null
|
signal: FutureSignal | null
|
||||||
@@ -33,64 +60,160 @@ interface Props {
|
|||||||
export function FutureAvailabilityContextPanel({ match: _match, signal }: Props) {
|
export function FutureAvailabilityContextPanel({ match: _match, signal }: Props) {
|
||||||
if (!signal) return null
|
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 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 (
|
return (
|
||||||
<Paper sx={{ p: 2.5, mb: 2 }}>
|
<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 */}
|
{/* Mandatory disclaimer */}
|
||||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
<Alert severity="warning" sx={{ mb: 2, fontSize: '0.8rem' }}>
|
||||||
{signal.disclaimer}
|
{signal.disclaimer}
|
||||||
</Alert>
|
</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 && (
|
{signal.signalType && (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 160 }}>Signaltyp</Typography>
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Signaltyp</Typography>
|
||||||
<Typography variant="body2">{SIGNAL_TYPE_LABELS[signal.signalType] ?? signal.signalType}</Typography>
|
<Chip label={SIGNAL_TYPE_LABELS[signal.signalType] ?? signal.signalType} size="small" sx={{ bgcolor: '#ede9fe', color: '#6d28d9', fontWeight: 600 }} />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 160 }}>Konfidenz</Typography>
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Wahrscheinlichkeit</Typography>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
<Typography variant="body2" sx={{ fontWeight: 700, color: probPct >= 70 ? '#1a7a4a' : probPct >= 50 ? '#d97706' : '#c0392b' }}>{probPct}%</Typography>
|
||||||
{Math.round(signal.confidenceScore * 100)}%
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 160 }}>Wahrscheinlichkeit</Typography>
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Konfidenz</Typography>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>{confPct}%</Typography>
|
||||||
{Math.round(signal.probability * 100)}%
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 160 }}>Zeithorizont</Typography>
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Zeithorizont</Typography>
|
||||||
<Typography variant="body2">~{signal.timeHorizonMonths} Monate</Typography>
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>~{signal.timeHorizonMonths} Monate</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
{signal.areaSqmEstimate && (
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 160 }}>Vertraulichkeit</Typography>
|
<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" />
|
<Chip label={sensitiveMeta.label} size="small" color={sensitiveMeta.color} variant="outlined" />
|
||||||
</Box>
|
</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>
|
</Box>
|
||||||
|
|
||||||
{signal.evidence?.summary && (
|
<Divider sx={{ mb: 2 }} />
|
||||||
<Box sx={{ mt: 1.5, p: 1.5, bgcolor: '#f8fafc', borderRadius: 1 }}>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
|
{/* AI Summary */}
|
||||||
Evidenz
|
{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>
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Evidence */}
|
||||||
|
{signal.evidence?.summary && (
|
||||||
|
<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>
|
<Typography variant="body2" color="text.secondary">{signal.evidence.summary}</Typography>
|
||||||
</Box>
|
</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>
|
||||||
|
)}
|
||||||
|
{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>
|
</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 type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||||
import { UnifiedResultCard } from './UnifiedResultCard'
|
import { UnifiedResultCard } from './UnifiedResultCard'
|
||||||
import { SCORE_THEME } from '../shared/scoreTheme'
|
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) {
|
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)
|
.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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
{/* Real properties — scored tiers */}
|
||||||
{tiers.map((tier, idx) => (
|
{tiers.map((tier, idx) => (
|
||||||
<Box key={tier.key}>
|
<Box key={tier.key}>
|
||||||
<TierHeader
|
<TierHeader
|
||||||
@@ -87,21 +143,17 @@ export function UnifiedResultFeed({ results, view = 'list' }: Props) {
|
|||||||
count={tier.items.length}
|
count={tier.items.length}
|
||||||
isFirst={idx === 0}
|
isFirst={idx === 0}
|
||||||
/>
|
/>
|
||||||
{view === 'grid' ? (
|
{renderCards(tier.items)}
|
||||||
<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>
|
||||||
) : (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
|
||||||
{tier.items.map(result => (
|
|
||||||
<UnifiedResultCard key={result.matchId} result={result} view="list" />
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Schattenmarkt signals — dedicated section */}
|
||||||
|
{signalResults.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<SchattenmarktSectionHeader count={signalResults.length} />
|
||||||
|
{renderCards(signalResults)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export function buildMatchCardViewModel(
|
|||||||
isStaleData: false,
|
isStaleData: false,
|
||||||
// Schattenmarkt signal fields
|
// Schattenmarkt signal fields
|
||||||
signalSourceType: signal?.source?.type,
|
signalSourceType: signal?.source?.type,
|
||||||
|
signalSourceUrl: signal?.source?.url,
|
||||||
signalSourceCredibility: signal?.source?.credibility,
|
signalSourceCredibility: signal?.source?.credibility,
|
||||||
signalProbability: signal?.probability,
|
signalProbability: signal?.probability,
|
||||||
signalMarketIndicator: signal?.marketIndicator,
|
signalMarketIndicator: signal?.marketIndicator,
|
||||||
|
|||||||
Reference in New Issue
Block a user