feat: F021 market intelligence & signal discovery

Signal domain types, mock data (8 signals), provider interface + MockupMarketIntelligenceProvider, marketIntelligenceService, TanStack Query hooks, 10 ops components (SignalInbox, MarketSignalCard, MarketSignalFilterBar, MarketSignalSkeleton, MarketSignalEmptyState, SignalConfidenceBadge, SourceReliabilityBadge, SensitivityWarningPanel, SignalConversionPanel, SignalEvidenceList), MarketIntelligence page wired into AppShell nav and App router.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-15 17:07:58 +02:00
parent 3cd2e83b25
commit cb18f3c381
17 changed files with 1389 additions and 4 deletions
+123
View File
@@ -0,0 +1,123 @@
import { Box, Chip, Typography } from '@mui/material'
import {
Building2, FileText, Newspaper, TrendingUp, FileCheck2,
HardHat, Database, CalendarClock, Search, PenLine,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import {
MARKET_SIGNAL_SOURCE_LABELS,
SIGNAL_PROCESSING_STATUS_LABELS,
SIGNAL_PROCESSING_STATUS_COLORS,
MarketSignalSourceCategory,
} from '../../domain/marketSignal'
import type { MarketSignal } from '../../domain/marketSignal'
import { SensitivityLevel } from '../../domain/enums'
import { SignalConfidenceBadge } from './SignalConfidenceBadge'
const SOURCE_ICONS: Record<string, LucideIcon> = {
PUBLIC_LISTING_PLATFORM: Building2,
BUILDING_PERMIT_REGISTER: FileText,
COMPANY_NEWS: Newspaper,
JOB_GROWTH_SIGNAL: TrendingUp,
COMMERCIAL_REGISTER: FileCheck2,
INFRASTRUCTURE_PROJECT: HardHat,
PORTFOLIO_IMPORT: Database,
LEASE_EXPIRY_DATA: CalendarClock,
USER_DEMAND_SIGNAL: Search,
MANUAL_ANALYST_SIGNAL: PenLine,
}
const SENSITIVITY_COLORS: Record<string, string> = {
[SensitivityLevel.INTERNAL]: '#f59e0b',
[SensitivityLevel.CONFIDENTIAL]: '#ef4444',
[SensitivityLevel.RESTRICTED]: '#7c3aed',
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: '2-digit' })
}
interface MarketSignalCardProps {
signal: MarketSignal
selected: boolean
onClick: () => void
}
export function MarketSignalCard({ signal, selected, onClick }: MarketSignalCardProps) {
const Icon = SOURCE_ICONS[signal.sourceCategory] ?? Building2
const { bg, fg } = SIGNAL_PROCESSING_STATUS_COLORS[signal.processingStatus]
const sensitivityColor = SENSITIVITY_COLORS[signal.sensitivityLevel]
return (
<Box
onClick={onClick}
sx={{
px: 1.5,
py: 1.25,
borderBottom: '1px solid #f1f5f9',
borderLeft: selected ? '3px solid #1e3a5f' : '3px solid transparent',
bgcolor: selected ? 'rgba(30,58,95,0.04)' : 'transparent',
cursor: 'pointer',
transition: 'background-color 0.12s ease',
'&:hover': { bgcolor: selected ? 'rgba(30,58,95,0.06)' : 'rgba(0,0,0,0.02)' },
}}
>
{/* Row 1: source + sensitivity */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Icon size={12} color="#64748b" />
<Typography sx={{ fontSize: '0.65rem', color: '#64748b' }}>
{MARKET_SIGNAL_SOURCE_LABELS[signal.sourceCategory as MarketSignalSourceCategory]}
</Typography>
</Box>
{sensitivityColor && (
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: sensitivityColor,
flexShrink: 0,
}}
/>
)}
</Box>
{/* Row 2: title */}
<Typography
sx={{
fontSize: '0.8125rem',
fontWeight: 500,
color: '#1e293b',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
lineHeight: 1.4,
mb: 0.5,
}}
>
{signal.title}
</Typography>
{/* Row 3: location + date */}
<Typography sx={{ fontSize: '0.7rem', color: '#94a3b8', mb: 0.5 }}>
{signal.location} · {formatDate(signal.detectedAt)}
</Typography>
{/* Row 4: status + confidence */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
<Chip
size="small"
label={SIGNAL_PROCESSING_STATUS_LABELS[signal.processingStatus]}
sx={{
bgcolor: bg, color: fg, border: 'none', fontWeight: 600,
fontSize: '0.65rem', height: 18,
}}
/>
<SignalConfidenceBadge score={signal.confidenceScore} />
</Box>
</Box>
)
}
@@ -0,0 +1,69 @@
import { Box, Typography } from '@mui/material'
import { Inbox, MousePointerClick, SearchX, AlertTriangle } from 'lucide-react'
type EmptyVariant = 'no-selection' | 'empty-inbox' | 'no-results' | 'not-found'
const VARIANTS: Record<EmptyVariant, { icon: typeof Inbox; title: string; subtitle: string }> = {
'no-selection': {
icon: MousePointerClick,
title: 'Signal auswählen',
subtitle: 'Wählen Sie ein Signal aus der Liste, um die Details anzuzeigen.',
},
'empty-inbox': {
icon: Inbox,
title: 'Keine Signale',
subtitle: 'Es wurden noch keine Marktzeichen erkannt. Starten Sie eine Intelligence-Analyse.',
},
'no-results': {
icon: SearchX,
title: 'Keine Ergebnisse',
subtitle: 'Keine Signale entsprechen den aktiven Filtern. Filter anpassen oder zurücksetzen.',
},
'not-found': {
icon: AlertTriangle,
title: 'Signal nicht gefunden',
subtitle: 'Das ausgewählte Signal konnte nicht geladen werden.',
},
}
export function MarketSignalEmptyState({ variant }: { variant: EmptyVariant }) {
const { icon: Icon, title, subtitle } = VARIANTS[variant]
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
minHeight: 300,
gap: 1.5,
p: 4,
color: 'text.secondary',
}}
>
<Box
sx={{
width: 48,
height: 48,
borderRadius: '50%',
bgcolor: 'rgba(148,163,184,0.12)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Icon size={24} color="#94a3b8" />
</Box>
<Box sx={{ textAlign: 'center', maxWidth: 320 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary', mb: 0.5 }}>
{title}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{subtitle}
</Typography>
</Box>
</Box>
)
}
@@ -0,0 +1,106 @@
import { Box, Button, Chip, Typography } from '@mui/material'
import { X } from 'lucide-react'
import {
MarketSignalSourceCategory,
MARKET_SIGNAL_SOURCE_LABELS,
SignalProcessingStatus,
SIGNAL_PROCESSING_STATUS_LABELS,
SIGNAL_PROCESSING_STATUS_COLORS,
} from '../../domain/marketSignal'
import type { MarketSignalFilters } from '../../domain/marketSignal'
interface MarketSignalFilterBarProps {
filters: MarketSignalFilters
onChange: (f: MarketSignalFilters) => void
}
const SOURCE_OPTIONS = Object.values(MarketSignalSourceCategory)
const STATUS_OPTIONS: SignalProcessingStatus[] = [
SignalProcessingStatus.NEEDS_REVIEW,
SignalProcessingStatus.ENRICHED,
SignalProcessingStatus.APPROVED_AS_SIGNAL,
SignalProcessingStatus.DETECTED,
SignalProcessingStatus.CONVERTED_TO_FUTURE_AVAILABILITY,
SignalProcessingStatus.REJECTED,
]
export function MarketSignalFilterBar({ filters, onChange }: MarketSignalFilterBarProps) {
const hasFilters = !!(filters.sourceCategory || filters.processingStatus)
return (
<Box sx={{ px: 1.5, py: 1, borderBottom: '1px solid #f1f5f9' }}>
{/* Source category */}
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 0.5, fontWeight: 600 }}>
Quelle
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mb: 1 }}>
{SOURCE_OPTIONS.map((cat) => {
const active = filters.sourceCategory === cat
return (
<Chip
key={cat}
label={MARKET_SIGNAL_SOURCE_LABELS[cat]}
size="small"
clickable
onClick={() =>
onChange({ ...filters, sourceCategory: active ? undefined : cat })
}
sx={{
fontSize: '0.65rem',
height: 20,
bgcolor: active ? '#1e3a5f' : 'transparent',
color: active ? '#fff' : 'text.secondary',
border: '1px solid',
borderColor: active ? '#1e3a5f' : 'divider',
'&:hover': { bgcolor: active ? '#162d4a' : 'rgba(0,0,0,0.04)' },
}}
/>
)
})}
</Box>
{/* Processing status */}
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 0.5, fontWeight: 600 }}>
Status
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mb: hasFilters ? 1 : 0 }}>
{STATUS_OPTIONS.map((status) => {
const active = filters.processingStatus === status
const { bg, fg } = SIGNAL_PROCESSING_STATUS_COLORS[status]
return (
<Chip
key={status}
label={SIGNAL_PROCESSING_STATUS_LABELS[status]}
size="small"
clickable
onClick={() =>
onChange({ ...filters, processingStatus: active ? undefined : status })
}
sx={{
fontSize: '0.65rem',
height: 20,
bgcolor: active ? bg : 'transparent',
color: active ? fg : 'text.secondary',
border: '1px solid',
borderColor: active ? fg : 'divider',
fontWeight: active ? 600 : 400,
'&:hover': { bgcolor: bg },
}}
/>
)
})}
</Box>
{hasFilters && (
<Button
size="small"
startIcon={<X size={12} />}
onClick={() => onChange({})}
sx={{ color: 'text.secondary', textTransform: 'none', fontSize: '0.75rem', p: 0 }}
>
Filter zurücksetzen
</Button>
)}
</Box>
)
}
@@ -0,0 +1,26 @@
import { Box, Skeleton } from '@mui/material'
export function MarketSignalSkeleton() {
return (
<Box
sx={{
p: 1.5,
borderBottom: '1px solid #f1f5f9',
display: 'flex',
flexDirection: 'column',
gap: 0.75,
}}
>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Skeleton variant="rounded" width={80} height={20} />
<Skeleton variant="rounded" width={60} height={20} />
</Box>
<Skeleton variant="text" sx={{ fontSize: '0.875rem' }} width="90%" />
<Skeleton variant="text" sx={{ fontSize: '0.75rem' }} width="60%" />
<Box sx={{ display: 'flex', gap: 1 }}>
<Skeleton variant="rounded" width={90} height={20} />
<Skeleton variant="rounded" width={70} height={20} />
</Box>
</Box>
)
}
@@ -0,0 +1,33 @@
import { Alert, AlertTitle } from '@mui/material'
import { Lock } from 'lucide-react'
import { SensitivityLevel } from '../../domain/enums'
interface SensitivityWarningPanelProps {
sensitivityLevel: SensitivityLevel
}
export function SensitivityWarningPanel({ sensitivityLevel }: SensitivityWarningPanelProps) {
if (
sensitivityLevel === SensitivityLevel.PUBLIC ||
sensitivityLevel === SensitivityLevel.INTERNAL
) {
return null
}
const isConfidential = sensitivityLevel === SensitivityLevel.CONFIDENTIAL
return (
<Alert
severity="warning"
icon={<Lock size={18} />}
sx={{ mb: 2 }}
>
<AlertTitle sx={{ fontWeight: 600 }}>
{isConfidential ? 'Vertrauliche Daten' : 'Zugriff eingeschränkt'}
</AlertTitle>
{isConfidential
? 'Dieser Hinweis enthält vertrauliche Informationen aus internen oder Partnerdaten. Keine Weitergabe an Demand User. Nur für berechtigte Personen sichtbar.'
: 'Der Zugriff auf diesen Hinweis ist eingeschränkt. Verbreitung und Veröffentlichung ohne Freigabe nicht zulässig.'}
</Alert>
)
}
@@ -0,0 +1,32 @@
import { Chip } from '@mui/material'
import { Target } from 'lucide-react'
interface SignalConfidenceBadgeProps {
score: number
size?: 'small' | 'medium'
}
function getColor(score: number): { bg: string; fg: string } {
if (score >= 0.8) return { bg: 'rgba(34,197,94,0.12)', fg: '#16a34a' }
if (score >= 0.6) return { bg: 'rgba(99,102,241,0.12)', fg: '#4f46e5' }
if (score >= 0.4) return { bg: 'rgba(245,158,11,0.12)', fg: '#d97706' }
return { bg: 'rgba(239,68,68,0.12)', fg: '#dc2626' }
}
export function SignalConfidenceBadge({ score, size = 'small' }: SignalConfidenceBadgeProps) {
const { bg, fg } = getColor(score)
return (
<Chip
size={size}
icon={<Target size={11} color={fg} />}
label={`Konfidenz ${Math.round(score * 100)}%`}
sx={{
bgcolor: bg,
color: fg,
border: 'none',
fontWeight: 600,
fontSize: size === 'small' ? '0.7rem' : '0.75rem',
}}
/>
)
}
@@ -0,0 +1,81 @@
import { Box, Button, CircularProgress, Divider, Typography } from '@mui/material'
import { ArrowRight, Zap } from 'lucide-react'
import type { MarketSignal } from '../../domain/marketSignal'
import { SignalProcessingStatus } from '../../domain/marketSignal'
import { useConvertToFutureSignal } from '../../hooks/useMarketSignals'
const ELIGIBLE_STATUSES: SignalProcessingStatus[] = [
SignalProcessingStatus.ENRICHED,
SignalProcessingStatus.APPROVED_AS_SIGNAL,
]
interface SignalConversionPanelProps {
signal: MarketSignal
}
export function SignalConversionPanel({ signal }: SignalConversionPanelProps) {
const { mutate: convert, isPending, isSuccess } = useConvertToFutureSignal()
if (!ELIGIBLE_STATUSES.includes(signal.processingStatus)) return null
if (signal.possibleFutureSignalId || isSuccess) {
return (
<Box
sx={{
p: 2,
bgcolor: 'rgba(139,92,246,0.08)',
border: '1px solid rgba(139,92,246,0.2)',
borderRadius: 1,
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<Zap size={16} color="#7c3aed" />
<Typography variant="body2" sx={{ color: '#7c3aed', fontWeight: 500 }}>
Bereits in Future Availability überführt
{signal.possibleFutureSignalId && ` (${signal.possibleFutureSignalId})`}
</Typography>
</Box>
)
}
return (
<Box
sx={{
p: 2,
bgcolor: '#f8fafc',
border: '1px solid #e2e8f0',
borderRadius: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<ArrowRight size={16} color="#1e3a5f" />
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
In Future Availability überführen
</Typography>
</Box>
<Divider sx={{ my: 1 }} />
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 0.75 }}>
<strong>Vorgeschlagener Titel:</strong> {signal.title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1.5 }}>
<strong>Begründung:</strong> Signal hat ausreichend Evidenz und Source Reliability, um als
Future Availability Kandidat geführt zu werden. Manueller Review empfohlen.
</Typography>
<Button
variant="contained"
size="small"
disabled={isPending}
onClick={() => convert(signal.id)}
startIcon={isPending ? <CircularProgress size={14} sx={{ color: '#fff' }} /> : <Zap size={14} />}
sx={{ bgcolor: '#7c3aed', textTransform: 'none', '&:hover': { bgcolor: '#6d28d9' } }}
>
{isPending ? 'Wird überführt...' : 'Jetzt konvertieren'}
</Button>
</Box>
)
}
+90
View File
@@ -0,0 +1,90 @@
import { Box, Chip, Link, Typography } from '@mui/material'
import { FileText, Globe, PenLine, FileArchive } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type { SignalEvidence } from '../../domain/marketSignal'
import { EvidenceType } from '../../domain/marketSignal'
const EVIDENCE_ICONS: Record<string, LucideIcon> = {
[EvidenceType.TEXT_EXCERPT]: FileText,
[EvidenceType.URL_REFERENCE]: Globe,
[EvidenceType.ANALYST_NOTE]: PenLine,
[EvidenceType.DOCUMENT]: FileArchive,
}
const EVIDENCE_LABELS: Record<string, string> = {
[EvidenceType.TEXT_EXCERPT]: 'Textauszug',
[EvidenceType.URL_REFERENCE]: 'URL-Referenz',
[EvidenceType.ANALYST_NOTE]: 'Analyst-Notiz',
[EvidenceType.DOCUMENT]: 'Dokument',
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString('de-CH', {
day: '2-digit', month: '2-digit', year: '2-digit',
hour: '2-digit', minute: '2-digit',
})
}
interface SignalEvidenceListProps {
evidence: SignalEvidence[]
}
export function SignalEvidenceList({ evidence }: SignalEvidenceListProps) {
if (evidence.length === 0) {
return (
<Typography variant="body2" sx={{ color: 'text.secondary', fontStyle: 'italic' }}>
Keine Evidenz vorhanden.
</Typography>
)
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{evidence.map((ev) => {
const Icon = EVIDENCE_ICONS[ev.evidenceType] ?? FileText
return (
<Box
key={ev.id}
sx={{
p: 1.5,
bgcolor: '#f8fafc',
borderRadius: 1,
border: '1px solid #e2e8f0',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75 }}>
<Icon size={14} color="#64748b" />
<Typography sx={{ fontSize: '0.7rem', color: '#64748b', fontWeight: 600 }}>
{EVIDENCE_LABELS[ev.evidenceType]}
</Typography>
<Chip
size="small"
label={`${Math.round(ev.confidence * 100)}%`}
sx={{ bgcolor: 'rgba(99,102,241,0.1)', color: '#4f46e5', border: 'none', fontSize: '0.65rem', height: 18, ml: 'auto' }}
/>
</Box>
<Typography variant="body2" sx={{ color: '#1e293b', lineHeight: 1.5, mb: ev.sourceUrl ? 0.75 : 0 }}>
{ev.content}
</Typography>
{ev.sourceUrl && (
<Link
href={ev.sourceUrl}
target="_blank"
rel="noopener noreferrer"
sx={{ fontSize: '0.7rem', display: 'block' }}
>
{ev.sourceUrl}
</Link>
)}
<Typography sx={{ fontSize: '0.65rem', color: '#94a3b8', mt: 0.5 }}>
Abgerufen: {formatDate(ev.retrievedAt)}
</Typography>
</Box>
)
})}
</Box>
)
}
+97
View File
@@ -0,0 +1,97 @@
import { Box, Chip, TextField, Typography } from '@mui/material'
import { Search } from 'lucide-react'
import type { MarketSignal, MarketSignalFilters } from '../../domain/marketSignal'
import { MarketSignalCard } from './MarketSignalCard'
import { MarketSignalFilterBar } from './MarketSignalFilterBar'
import { MarketSignalSkeleton } from './MarketSignalSkeleton'
import { MarketSignalEmptyState } from './MarketSignalEmptyState'
interface SignalInboxProps {
signals: MarketSignal[]
isLoading: boolean
selectedId: string | null
onSelect: (id: string) => void
filters: MarketSignalFilters
onFiltersChange: (f: MarketSignalFilters) => void
}
export function SignalInbox({
signals,
isLoading,
selectedId,
onSelect,
filters,
onFiltersChange,
}: SignalInboxProps) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Inbox header */}
<Box
sx={{
px: 1.5,
py: 1.25,
borderBottom: '1px solid #e2e8f0',
display: 'flex',
alignItems: 'center',
gap: 1,
flexShrink: 0,
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600, flex: 1 }}>
Signal-Inbox
</Typography>
<Chip
label={isLoading ? '…' : signals.length}
size="small"
sx={{ bgcolor: '#1e3a5f', color: '#fff', fontSize: '0.7rem', height: 20 }}
/>
</Box>
{/* Search */}
<Box sx={{ px: 1.5, py: 1, borderBottom: '1px solid #f1f5f9', flexShrink: 0 }}>
<TextField
size="small"
placeholder="Suchen..."
fullWidth
value={filters.search ?? ''}
onChange={(e) => onFiltersChange({ ...filters, search: e.target.value || undefined })}
slotProps={{
input: {
startAdornment: <Search size={14} color="#94a3b8" style={{ marginRight: 6 }} />,
},
}}
sx={{ '& .MuiOutlinedInput-root': { fontSize: '0.8rem' } }}
/>
</Box>
{/* Filters */}
<Box sx={{ flexShrink: 0 }}>
<MarketSignalFilterBar filters={filters} onChange={onFiltersChange} />
</Box>
{/* Signal list */}
<Box sx={{ flex: 1, overflowY: 'auto' }}>
{isLoading ? (
Array.from({ length: 5 }).map((_, i) => <MarketSignalSkeleton key={i} />)
) : signals.length === 0 ? (
<MarketSignalEmptyState
variant={
filters.sourceCategory || filters.processingStatus || filters.search
? 'no-results'
: 'empty-inbox'
}
/>
) : (
signals.map((signal) => (
<MarketSignalCard
key={signal.id}
signal={signal}
selected={signal.id === selectedId}
onClick={() => onSelect(signal.id)}
/>
))
)}
</Box>
</Box>
)
}
@@ -0,0 +1,31 @@
import { Chip } from '@mui/material'
import { ShieldCheck } from 'lucide-react'
interface SourceReliabilityBadgeProps {
score: number
size?: 'small' | 'medium'
}
function getColor(score: number): { bg: string; fg: string } {
if (score >= 0.8) return { bg: 'rgba(34,197,94,0.12)', fg: '#16a34a' }
if (score >= 0.6) return { bg: 'rgba(245,158,11,0.12)', fg: '#d97706' }
return { bg: 'rgba(239,68,68,0.12)', fg: '#dc2626' }
}
export function SourceReliabilityBadge({ score, size = 'small' }: SourceReliabilityBadgeProps) {
const { bg, fg } = getColor(score)
return (
<Chip
size={size}
icon={<ShieldCheck size={11} color={fg} />}
label={`Quelle ${Math.round(score * 100)}%`}
sx={{
bgcolor: bg,
color: fg,
border: 'none',
fontWeight: 600,
fontSize: size === 'small' ? '0.7rem' : '0.75rem',
}}
/>
)
}