diff --git a/src/components/match-card/IntelligenceMatchCard.tsx b/src/components/match-card/IntelligenceMatchCard.tsx index 4a9827d..f2f9dba 100644 --- a/src/components/match-card/IntelligenceMatchCard.tsx +++ b/src/components/match-card/IntelligenceMatchCard.tsx @@ -4,6 +4,7 @@ import { Briefcase, Building2, CheckCircle2, + ExternalLink, FileCheck, FileText, Newspaper, @@ -42,6 +43,16 @@ const SIGNAL_TYPE_LABELS: Record = { SPACE_CONSOLIDATION: 'Flächenkonsolidierung', } +// Human-readable explanation of WHY a signal type is relevant to a space search +const SIGNAL_RELEVANCE_BRIDGE: Record = { + 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 = { 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 */} {/* Subtle pattern overlay */} @@ -86,25 +99,32 @@ function SignalHero({ vm }: { vm: MatchCardViewModel }) { {/* Company initials */} - + {initials} {/* Signal type label */} - - + + {signalLabel} + {/* Relevance bridge — why this appears in results */} + {relevanceBridge && ( + + {relevanceBridge} + + )} + {/* Verified badge */} {vm.signalIsVerified && ( @@ -153,10 +173,24 @@ function SignalMetaStrip({ vm }: { vm: MatchCardViewModel }) { {/* Source + credibility row */} {sourceMeta && ( - - {sourceMeta.icon} - {sourceMeta.label} - + vm.signalSourceUrl ? ( + + {sourceMeta.icon} + {sourceMeta.label} + + + ) : ( + + {sourceMeta.icon} + {sourceMeta.label} + + ) )} {credMeta && ( diff --git a/src/components/match-card/MatchCardViewModel.ts b/src/components/match-card/MatchCardViewModel.ts index ede5bd4..36f10a7 100644 --- a/src/components/match-card/MatchCardViewModel.ts +++ b/src/components/match-card/MatchCardViewModel.ts @@ -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 diff --git a/src/components/match-detail/FutureAvailabilityContextPanel.tsx b/src/components/match-detail/FutureAvailabilityContextPanel.tsx index 665bec9..09c251d 100644 --- a/src/components/match-detail/FutureAvailabilityContextPanel.tsx +++ b/src/components/match-detail/FutureAvailabilityContextPanel.tsx @@ -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 = { SPACE_CONSOLIDATION: 'Flächenkonsolidierung', } +const SIGNAL_RELEVANCE_BRIDGE: Record = { + 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 = { + JOB_POSTING: { label: 'Stelleninserate', icon: }, + PRESS: { label: 'Pressebericht', icon: }, + CONSTRUCTION_PERMIT: { label: 'Baubewilligung', icon: }, + COMPANY_REPORT: { label: 'Geschäftsbericht', icon: }, + MARKET_DATA: { label: 'Marktdaten', icon: }, + MANUAL: { label: 'Analyst', icon: }, +} + +const CREDIBILITY_META: Record = { + 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 = { CONFIDENTIAL: { label: 'Vertraulich', color: 'error' }, INTERNAL: { label: 'Intern', color: 'warning' }, PUBLIC: { label: 'Öffentlich', color: 'default' }, } -const REVIEW_STATUS_META: Record = { - 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 ( - Zukunftssignal – Kontext + + + Schattenmarkt-Signal + {signal.isVerified && ( + + + Verifiziert + + )} + {/* Mandatory disclaimer */} - + {signal.disclaimer} - + {/* Relevance bridge */} + {relevanceBridge && ( + + + + Warum erscheint dieses Signal in Ihrer Suche? + {relevanceBridge} + + + )} + + {/* Key metrics row */} + {signal.signalType && ( - - Signaltyp - {SIGNAL_TYPE_LABELS[signal.signalType] ?? signal.signalType} + + Signaltyp + )} - - Konfidenz - - {Math.round(signal.confidenceScore * 100)}% - + + Wahrscheinlichkeit + = 70 ? '#1a7a4a' : probPct >= 50 ? '#d97706' : '#c0392b' }}>{probPct}% - - Wahrscheinlichkeit - - {Math.round(signal.probability * 100)}% - + + Konfidenz + {confPct}% - - Zeithorizont - ~{signal.timeHorizonMonths} Monate + + Zeithorizont + ~{signal.timeHorizonMonths} Monate - - Vertraulichkeit + {signal.areaSqmEstimate && ( + + Flächenschätzung + ~{signal.areaSqmEstimate.toLocaleString('de-CH')} m² + + )} + + Vertraulichkeit - {reviewMeta && ( - - Prüfstatus - - - )} - - Quellglaubwürdigkeit - {signal.source.credibility} - + + + {/* AI Summary */} + {signal.aiSummary && ( + + + + KI-Analyse + + + + {signal.aiSummary} + + + + )} + + {/* Evidence */} {signal.evidence?.summary && ( - - - Evidenz + + Evidenz + + {signal.evidence.summary} + + + )} + + + + {/* Source section */} + Quelle + + {sourceMeta && ( + + {sourceMeta.icon} + {sourceMeta.label} + + )} + {credMeta && ( + + + {credMeta.label} + + )} + {signal.source.publishedAt && ( + + Publiziert: {new Date(signal.source.publishedAt).toLocaleDateString('de-CH', { day: '2-digit', month: 'long', year: 'numeric' })} - {signal.evidence.summary} + )} + {signal.source.url && ( + + )} + + + {signal.evidence?.sourceUrls && signal.evidence.sourceUrls.length > 0 && ( + + + Weitere Belege ({signal.evidence.sourceUrls.length}) + + + {signal.evidence.sourceUrls.map((url, i) => ( + + + {url} + + ))} + )} diff --git a/src/components/results/UnifiedResultFeed.tsx b/src/components/results/UnifiedResultFeed.tsx index 8eef264..64ce79c 100644 --- a/src/components/results/UnifiedResultFeed.tsx +++ b/src/components/results/UnifiedResultFeed.tsx @@ -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 ( + + + + + + + Schattenmarkt + + + + + + } + sx={{ + mb: 2, + bgcolor: '#faf5ff', + border: '1px solid #e9d5ff', + color: '#4c1d95', + '& .MuiAlert-icon': { color: '#7c3aed' }, + }} + > + Was sind Schattenmarkt-Signale? 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 keine bestätigten Angebote — aber frühzeitige Hinweise, die Sie im Auge behalten sollten. Klicken Sie auf Details für Quellen und die vollständige KI-Analyse. + + + ) +} + 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' ? ( + + {items.map(result => ( + + ))} + + ) : ( + + {items.map(result => ( + + ))} + + ) + } + return ( + {/* Real properties — scored tiers */} {tiers.map((tier, idx) => ( - {view === 'grid' ? ( - - {tier.items.map(result => ( - - ))} - - ) : ( - - {tier.items.map(result => ( - - ))} - - )} + {renderCards(tier.items)} ))} + + {/* Schattenmarkt signals — dedicated section */} + {signalResults.length > 0 && ( + + + {renderCards(signalResults)} + + )} ) } diff --git a/src/features/matching/matchCardAdapter.ts b/src/features/matching/matchCardAdapter.ts index ad7cb86..a0403d2 100644 --- a/src/features/matching/matchCardAdapter.ts +++ b/src/features/matching/matchCardAdapter.ts @@ -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,