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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user