feat: F022 source acquisition, crawling & connector abstraction

This commit is contained in:
Benjamin Sutter
2026-05-15 15:32:53 +02:00
parent 8efd6e1974
commit fce0f684af
20 changed files with 1647 additions and 0 deletions
@@ -0,0 +1,144 @@
import { Box, Chip, Divider, Drawer, IconButton, Typography } from '@mui/material'
import { X, AlertCircle, AlertTriangle, Zap } from 'lucide-react'
import type { ConnectorRun } from '../../domain/dataSource'
import { CONNECTOR_RUN_STATUS_LABELS, CONNECTOR_RUN_STATUS_COLORS } from '../../domain/dataSource'
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', second: '2-digit',
})
}
interface ConnectorRunDetailDrawerProps {
run: ConnectorRun | null
onClose: () => void
}
export function ConnectorRunDetailDrawer({ run, onClose }: ConnectorRunDetailDrawerProps) {
return (
<Drawer
anchor="right"
open={run !== null}
onClose={onClose}
slotProps={{ paper: { sx: { width: 400 } } }}
>
{run && (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* Header */}
<Box sx={{ px: 3, py: 2, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, flex: 1 }}>
Import-Run Details
</Typography>
<IconButton size="small" onClick={onClose}>
<X size={16} />
</IconButton>
</Box>
<Box sx={{ flex: 1, overflowY: 'auto', p: 3 }}>
{/* Status */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Chip
size="small"
label={CONNECTOR_RUN_STATUS_LABELS[run.status]}
sx={{
bgcolor: CONNECTOR_RUN_STATUS_COLORS[run.status].bg,
color: CONNECTOR_RUN_STATUS_COLORS[run.status].fg,
border: 'none',
fontWeight: 600,
}}
/>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{run.id}
</Typography>
</Box>
{/* Timestamps */}
<Box sx={{ mb: 2 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>
Gestartet: <strong>{formatDate(run.startedAt)}</strong>
</Typography>
{run.finishedAt && (
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>
Beendet: <strong>{formatDate(run.finishedAt)}</strong>
</Typography>
)}
</Box>
<Divider sx={{ mb: 2 }} />
{/* Stats grid */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5, mb: 2 }}>
{[
{ label: 'Erkannt', value: run.itemsDetected, color: '#1e293b' },
{ label: 'Normalisiert', value: run.itemsNormalized, color: '#15803d' },
{ label: 'Abgelehnt', value: run.itemsRejected, color: run.itemsRejected > 0 ? '#dc2626' : '#64748b' },
{ label: 'Signale erstellt', value: run.signalsCreated, color: '#7c3aed' },
].map(({ label, value, color }) => (
<Box key={label} sx={{ p: 1.5, bgcolor: '#f8fafc', borderRadius: 1, border: '1px solid #e2e8f0' }}>
<Typography sx={{ fontSize: '1.25rem', fontWeight: 700, color, lineHeight: 1 }}>
{value}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>{label}</Typography>
</Box>
))}
</Box>
{/* Summary */}
<Divider sx={{ mb: 2 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.75 }}>
Zusammenfassung
</Typography>
<Typography variant="body2" sx={{ color: '#1e293b', lineHeight: 1.5, mb: 2 }}>
{run.runSummary}
</Typography>
{/* Signals note */}
{run.signalsCreated > 0 && (
<Box sx={{ p: 1.5, bgcolor: 'rgba(124,58,237,0.06)', border: '1px solid rgba(124,58,237,0.2)', borderRadius: 1, display: 'flex', gap: 1, mb: 2 }}>
<Zap size={14} color="#7c3aed" style={{ flexShrink: 0, marginTop: 2 }} />
<Typography variant="body2" sx={{ color: '#7c3aed', fontSize: '0.78rem' }}>
{run.signalsCreated} Marktsignal{run.signalsCreated !== 1 ? 'e' : ''} aus diesem Run verfügbar in Market Intelligence.
</Typography>
</Box>
)}
{/* Errors */}
{run.errors.length > 0 && (
<>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.75 }}>
Fehler
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 2 }}>
{run.errors.map((err, i) => (
<Box key={i} sx={{ display: 'flex', gap: 0.75, p: 1, bgcolor: 'rgba(239,68,68,0.06)', borderRadius: 1 }}>
<AlertCircle size={13} color="#dc2626" style={{ flexShrink: 0, marginTop: 1 }} />
<Typography variant="caption" sx={{ color: '#dc2626', lineHeight: 1.4 }}>{err}</Typography>
</Box>
))}
</Box>
</>
)}
{/* Warnings */}
{run.warnings.length > 0 && (
<>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.75 }}>
Warnungen
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{run.warnings.map((w, i) => (
<Box key={i} sx={{ display: 'flex', gap: 0.75, p: 1, bgcolor: 'rgba(234,179,8,0.06)', borderRadius: 1 }}>
<AlertTriangle size={13} color="#a16207" style={{ flexShrink: 0, marginTop: 1 }} />
<Typography variant="caption" sx={{ color: '#a16207', lineHeight: 1.4 }}>{w}</Typography>
</Box>
))}
</Box>
</>
)}
</Box>
</Box>
)}
</Drawer>
)
}
+81
View File
@@ -0,0 +1,81 @@
import { Box, Chip, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material'
import type { ConnectorRun } from '../../domain/dataSource'
import { CONNECTOR_RUN_STATUS_LABELS, CONNECTOR_RUN_STATUS_COLORS } from '../../domain/dataSource'
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',
})
}
function formatDuration(start: string, end?: string): string {
if (!end) return 'läuft...'
const ms = new Date(end).getTime() - new Date(start).getTime()
if (ms < 60_000) return `${Math.round(ms / 1000)}s`
return `${Math.round(ms / 60_000)}m`
}
interface ConnectorRunTableProps {
runs: ConnectorRun[]
onSelectRun: (run: ConnectorRun) => void
}
export function ConnectorRunTable({ runs, onSelectRun }: ConnectorRunTableProps) {
if (runs.length === 0) {
return (
<Typography variant="body2" sx={{ color: 'text.secondary', fontStyle: 'italic', py: 2 }}>
Keine Import-Runs vorhanden.
</Typography>
)
}
return (
<Box sx={{ overflowX: 'auto' }}>
<Table size="small">
<TableHead>
<TableRow sx={{ '& th': { fontSize: '0.72rem', fontWeight: 600, color: '#64748b', borderBottom: '2px solid #e2e8f0' } }}>
<TableCell>Gestartet</TableCell>
<TableCell>Status</TableCell>
<TableCell align="right">Erkannt</TableCell>
<TableCell align="right">Normalisiert</TableCell>
<TableCell align="right">Signale</TableCell>
<TableCell align="right">Dauer</TableCell>
</TableRow>
</TableHead>
<TableBody>
{runs.map((run) => {
const { bg, fg } = CONNECTOR_RUN_STATUS_COLORS[run.status]
return (
<TableRow
key={run.id}
hover
onClick={() => onSelectRun(run)}
sx={{ cursor: 'pointer', '& td': { fontSize: '0.78rem', py: 0.75 } }}
>
<TableCell>{formatDate(run.startedAt)}</TableCell>
<TableCell>
<Chip
size="small"
label={CONNECTOR_RUN_STATUS_LABELS[run.status]}
sx={{ bgcolor: bg, color: fg, border: 'none', fontSize: '0.68rem', fontWeight: 600 }}
/>
</TableCell>
<TableCell align="right">{run.itemsDetected}</TableCell>
<TableCell align="right">{run.itemsNormalized}</TableCell>
<TableCell align="right">
<Typography sx={{ fontSize: '0.78rem', color: run.signalsCreated > 0 ? '#7c3aed' : 'text.primary', fontWeight: run.signalsCreated > 0 ? 600 : 400 }}>
{run.signalsCreated}
</Typography>
</TableCell>
<TableCell align="right" sx={{ color: '#94a3b8' }}>
{formatDuration(run.startedAt, run.finishedAt)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</Box>
)
}
@@ -0,0 +1,36 @@
import { Box, Chip } from '@mui/material'
interface DataCategoryBadgeListProps {
categories: string[]
max?: number
}
export function DataCategoryBadgeList({ categories, max }: DataCategoryBadgeListProps) {
const visible = max ? categories.slice(0, max) : categories
const overflow = max ? Math.max(0, categories.length - max) : 0
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{visible.map((cat) => (
<Chip
key={cat}
size="small"
label={cat}
sx={{
bgcolor: 'rgba(30,58,95,0.07)',
color: '#1e3a5f',
border: 'none',
fontSize: '0.68rem',
}}
/>
))}
{overflow > 0 && (
<Chip
size="small"
label={`+${overflow}`}
sx={{ bgcolor: 'rgba(0,0,0,0.06)', color: 'text.secondary', border: 'none', fontSize: '0.68rem' }}
/>
)}
</Box>
)
}
@@ -0,0 +1,61 @@
import { Box, LinearProgress, Tooltip, Typography } from '@mui/material'
function scoreColor(score: number): string {
if (score >= 0.85) return '#15803d'
if (score >= 0.65) return '#a16207'
return '#dc2626'
}
interface ReliabilityScorePanelProps {
score: number
compact?: boolean
}
export function ReliabilityScorePanel({ score, compact = false }: ReliabilityScorePanelProps) {
const pct = Math.round(score * 100)
const color = scoreColor(score)
if (compact) {
return (
<Tooltip title={`Reliabilität: ${pct}%`}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<LinearProgress
variant="determinate"
value={pct}
sx={{
width: 48,
height: 4,
borderRadius: 2,
bgcolor: 'rgba(0,0,0,0.08)',
'& .MuiLinearProgress-bar': { bgcolor: color, borderRadius: 2 },
}}
/>
<Typography sx={{ fontSize: '0.7rem', color, fontWeight: 600 }}>{pct}%</Typography>
</Box>
</Tooltip>
)
}
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Source Reliability
</Typography>
<Typography variant="caption" sx={{ color, fontWeight: 700 }}>
{pct}%
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={pct}
sx={{
height: 6,
borderRadius: 3,
bgcolor: 'rgba(0,0,0,0.08)',
'& .MuiLinearProgress-bar': { bgcolor: color, borderRadius: 3 },
}}
/>
</Box>
)
}
+125
View File
@@ -0,0 +1,125 @@
import { Box, Chip, Typography } from '@mui/material'
import {
Plug2, FileSpreadsheet, Upload, Globe, Rss,
Database, FileText, PenLine, Bot,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type { DataSource } from '../../domain/dataSource'
import { DATA_SOURCE_TYPE_LABELS } from '../../domain/dataSource'
import { SourceHealthBadge } from './SourceHealthBadge'
import { TermsStatusBadge } from './TermsStatusBadge'
import { ReliabilityScorePanel } from './ReliabilityScorePanel'
const TYPE_ICONS: Record<string, LucideIcon> = {
API_CONNECTOR: Plug2,
CSV_IMPORT: FileSpreadsheet,
MANUAL_UPLOAD: Upload,
PUBLIC_WEB_SOURCE: Globe,
PARTNER_FEED: Rss,
INTERNAL_PORTFOLIO_EXPORT: Database,
CONTRACT_METADATA_IMPORT: FileText,
ANALYST_ENTRY: PenLine,
FUTURE_CRAWLER_STUB: Bot,
}
function formatDate(iso?: string): string {
if (!iso) return '—'
return new Date(iso).toLocaleString('de-CH', {
day: '2-digit', month: '2-digit', year: '2-digit',
hour: '2-digit', minute: '2-digit',
})
}
interface SourceCardProps {
source: DataSource
selected: boolean
onClick: () => void
}
export function SourceCard({ source, selected, onClick }: SourceCardProps) {
const Icon = TYPE_ICONS[source.sourceType] ?? Database
return (
<Box
onClick={onClick}
sx={{
px: 2,
py: 1.5,
cursor: 'pointer',
borderBottom: '1px solid #f1f5f9',
borderLeft: selected ? '3px solid #1e3a5f' : '3px solid transparent',
bgcolor: selected ? 'rgba(30,58,95,0.04)' : '#fff',
'&:hover': { bgcolor: selected ? 'rgba(30,58,95,0.06)' : '#f8fafc' },
transition: 'background-color 0.15s ease',
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.25, mb: 0.75 }}>
<Box
sx={{
width: 28,
height: 28,
borderRadius: 1,
bgcolor: 'rgba(30,58,95,0.07)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
mt: 0.25,
}}
>
<Icon size={14} color="#1e3a5f" />
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="body2"
sx={{
fontWeight: 600,
fontSize: '0.8rem',
lineHeight: 1.3,
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
mb: 0.5,
}}
>
{source.name}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.7rem' }}>
{DATA_SOURCE_TYPE_LABELS[source.sourceType]}
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mb: 0.75 }}>
<SourceHealthBadge status={source.status} />
<TermsStatusBadge status={source.termsStatus} />
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<ReliabilityScorePanel score={source.reliabilityScore} compact />
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.65rem', flexShrink: 0 }}>
{formatDate(source.lastRunAt)}
</Typography>
</Box>
{source.regionCoverage.length > 0 && (
<Box sx={{ mt: 0.75, display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{source.regionCoverage.slice(0, 3).map((r) => (
<Chip
key={r}
size="small"
label={r}
sx={{ bgcolor: 'transparent', border: '1px solid #e2e8f0', color: '#64748b', fontSize: '0.65rem', height: 16 }}
/>
))}
{source.regionCoverage.length > 3 && (
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.65rem', alignSelf: 'center' }}>
+{source.regionCoverage.length - 3}
</Typography>
)}
</Box>
)}
</Box>
)
}
+265
View File
@@ -0,0 +1,265 @@
import { useState } from 'react'
import {
Box, Button, Chip, CircularProgress, Divider, Tooltip, Typography,
} from '@mui/material'
import {
Plug2, FileSpreadsheet, Upload, Globe, Rss,
Database, FileText, PenLine, Bot,
Play, Pause, Scale, RefreshCw, ExternalLink,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type { DataSource, ConnectorRun } from '../../domain/dataSource'
import {
DATA_SOURCE_TYPE_LABELS,
SourceStatus,
TermsStatus,
} from '../../domain/dataSource'
import { FreshnessBadge } from '../badges/FreshnessBadge'
import { SourceHealthBadge } from './SourceHealthBadge'
import { TermsStatusBadge } from './TermsStatusBadge'
import { ReliabilityScorePanel } from './ReliabilityScorePanel'
import { DataCategoryBadgeList } from './DataCategoryBadgeList'
import { SourceErrorPanel } from './SourceErrorPanel'
import { ConnectorRunTable } from './ConnectorRunTable'
import { ConnectorRunDetailDrawer } from './ConnectorRunDetailDrawer'
import { MarketSignalEmptyState } from './MarketSignalEmptyState'
import {
useConnectorRuns,
useTriggerMockRun,
useUpdateSourceStatus,
useMarkTermsStatus,
} from '../../hooks/useDataSources'
const TYPE_ICONS: Record<string, LucideIcon> = {
API_CONNECTOR: Plug2,
CSV_IMPORT: FileSpreadsheet,
MANUAL_UPLOAD: Upload,
PUBLIC_WEB_SOURCE: Globe,
PARTNER_FEED: Rss,
INTERNAL_PORTFOLIO_EXPORT: Database,
CONTRACT_METADATA_IMPORT: FileText,
ANALYST_ENTRY: PenLine,
FUTURE_CRAWLER_STUB: Bot,
}
function formatDate(iso?: string): string {
if (!iso) return '—'
return new Date(iso).toLocaleString('de-CH', {
day: '2-digit', month: '2-digit', year: '2-digit',
hour: '2-digit', minute: '2-digit',
})
}
interface SourceDetailPanelProps {
source: DataSource | null
}
export function SourceDetailPanel({ source }: SourceDetailPanelProps) {
const [selectedRun, setSelectedRun] = useState<ConnectorRun | null>(null)
const { data: runs = [] } = useConnectorRuns(source?.id ?? null)
const { mutate: triggerRun, isPending: isRunning } = useTriggerMockRun()
const { mutate: updateStatus, isPending: isUpdating } = useUpdateSourceStatus()
const { mutate: markTerms, isPending: isMarkingTerms } = useMarkTermsStatus()
if (!source) return <MarketSignalEmptyState variant="not-found" />
const SourceIcon = TYPE_ICONS[source.sourceType] ?? Database
const isActionable = source.status !== SourceStatus.DISABLED
const RUN_ELIGIBLE: SourceStatus[] = [SourceStatus.ACTIVE, SourceStatus.ERROR]
const canRun = RUN_ELIGIBLE.includes(source.status)
return (
<Box sx={{ height: '100%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
{/* Header */}
<Box sx={{ p: 3, borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.5, mb: 1.5 }}>
<Box
sx={{
width: 36,
height: 36,
borderRadius: 1.5,
bgcolor: 'rgba(30,58,95,0.08)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<SourceIcon size={18} color="#1e3a5f" />
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 600, lineHeight: 1.3, mb: 0.25 }}>
{source.name}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{DATA_SOURCE_TYPE_LABELS[source.sourceType]}
{source.ownerOrganizationId && ` · Org ${source.ownerOrganizationId}`}
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
<SourceHealthBadge status={source.status} />
<TermsStatusBadge status={source.termsStatus} />
<FreshnessBadge status={source.freshnessStatus} />
</Box>
</Box>
{/* Body */}
<Box sx={{ p: 3, flex: 1 }}>
{source.errorState && <SourceErrorPanel errorState={source.errorState} />}
{/* Legal basis */}
<Box sx={{ p: 2, bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, mb: 2 }}>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, display: 'block', mb: 0.25 }}>
Rechtliche Grundlage
</Typography>
<Typography variant="body2" sx={{ color: '#1e293b', lineHeight: 1.5 }}>
{source.legalBasis}
</Typography>
</Box>
{/* Reliability */}
<Box sx={{ mb: 2 }}>
<ReliabilityScorePanel score={source.reliabilityScore} />
</Box>
{/* Run timestamps */}
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
<Box>
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>Letzter Run</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{formatDate(source.lastRunAt)}</Typography>
</Box>
<Box>
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>Nächster Run</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{formatDate(source.nextRunAt)}</Typography>
</Box>
</Box>
<Divider sx={{ mb: 2 }} />
{/* Data categories */}
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>Datenkategorien</Typography>
<Box sx={{ mb: 2 }}>
<DataCategoryBadgeList categories={source.dataCategories} />
</Box>
{/* Region coverage */}
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.75 }}>Regionen</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mb: 2 }}>
{source.regionCoverage.map((r) => (
<Chip
key={r}
size="small"
label={r}
sx={{ bgcolor: 'transparent', border: '1px solid #e2e8f0', color: '#475569', fontSize: '0.72rem' }}
/>
))}
</Box>
{/* Asset types */}
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.75 }}>Asset-Typen</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mb: 2 }}>
{source.supportedAssetTypes.map((a) => (
<Chip
key={a}
size="small"
label={a}
sx={{ bgcolor: 'rgba(30,58,95,0.06)', color: '#1e3a5f', border: 'none', fontSize: '0.72rem' }}
/>
))}
</Box>
{source.notes && (
<>
<Divider sx={{ mb: 2 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>Notizen</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', fontStyle: 'italic', lineHeight: 1.5, mb: 2 }}>
{source.notes}
</Typography>
</>
)}
{/* Actions */}
{isActionable && (
<>
<Divider sx={{ mb: 2 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5 }}>Aktionen</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 2 }}>
{canRun && (
<Button
size="small"
variant="contained"
startIcon={isRunning ? <CircularProgress size={12} sx={{ color: '#fff' }} /> : <Play size={14} />}
disabled={isRunning}
onClick={() => triggerRun(source.id)}
sx={{ bgcolor: '#1e3a5f', textTransform: 'none', fontSize: '0.8rem', '&:hover': { bgcolor: '#162d4a' } }}
>
{isRunning ? 'Läuft...' : 'Demo-Run starten'}
</Button>
)}
{source.status === SourceStatus.ACTIVE && (
<Button
size="small"
variant="outlined"
startIcon={<Pause size={14} />}
disabled={isUpdating}
onClick={() => updateStatus({ id: source.id, status: SourceStatus.PAUSED })}
sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: '#a16207', color: '#a16207', '&:hover': { borderColor: '#854d0e', bgcolor: 'rgba(161,98,7,0.04)' } }}
>
Pausieren
</Button>
)}
{source.status === SourceStatus.PAUSED && (
<Button
size="small"
variant="outlined"
startIcon={<RefreshCw size={14} />}
disabled={isUpdating}
onClick={() => updateStatus({ id: source.id, status: SourceStatus.ACTIVE })}
sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: '#15803d', color: '#15803d', '&:hover': { borderColor: '#166534', bgcolor: 'rgba(21,128,61,0.04)' } }}
>
Reaktivieren
</Button>
)}
{source.termsStatus !== TermsStatus.NEEDS_LEGAL_REVIEW && (
<Tooltip title="Rechtliche Prüfung anfordern">
<Button
size="small"
variant="outlined"
startIcon={<Scale size={14} />}
disabled={isMarkingTerms}
onClick={() => markTerms({ id: source.id, termsStatus: TermsStatus.NEEDS_LEGAL_REVIEW })}
sx={{ textTransform: 'none', fontSize: '0.8rem', color: 'text.secondary', borderColor: 'divider' }}
>
Rechtsprüfung anfordern
</Button>
</Tooltip>
)}
<Tooltip title="Generierte Signale ansehen (Demo)">
<Button
size="small"
variant="outlined"
startIcon={<ExternalLink size={14} />}
sx={{ textTransform: 'none', fontSize: '0.8rem', color: 'text.secondary', borderColor: 'divider' }}
>
Signale ansehen
</Button>
</Tooltip>
</Box>
</>
)}
{/* Connector runs */}
<Divider sx={{ mb: 2 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5 }}>
Import-Runs
</Typography>
<ConnectorRunTable runs={runs} onSelectRun={setSelectedRun} />
</Box>
<ConnectorRunDetailDrawer run={selectedRun} onClose={() => setSelectedRun(null)} />
</Box>
)
}
+23
View File
@@ -0,0 +1,23 @@
import { Alert, Typography } from '@mui/material'
import { AlertCircle } from 'lucide-react'
interface SourceErrorPanelProps {
errorState: string
}
export function SourceErrorPanel({ errorState }: SourceErrorPanelProps) {
return (
<Alert
severity="error"
icon={<AlertCircle size={16} />}
sx={{ mb: 2, fontSize: '0.8rem', '& .MuiAlert-message': { width: '100%' } }}
>
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.25 }}>
Fehlerzustand
</Typography>
<Typography variant="caption" sx={{ lineHeight: 1.4 }}>
{errorState}
</Typography>
</Alert>
)
}
+37
View File
@@ -0,0 +1,37 @@
import { Chip } from '@mui/material'
import { CheckCircle2, PauseCircle, AlertCircle, Clock, XCircle } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type { SourceStatus } from '../../domain/dataSource'
import { SOURCE_STATUS_LABELS, SOURCE_STATUS_COLORS } from '../../domain/dataSource'
const STATUS_ICONS: Record<SourceStatus, LucideIcon> = {
ACTIVE: CheckCircle2,
PAUSED: PauseCircle,
ERROR: AlertCircle,
PENDING_REVIEW: Clock,
DISABLED: XCircle,
}
interface SourceHealthBadgeProps {
status: SourceStatus
}
export function SourceHealthBadge({ status }: SourceHealthBadgeProps) {
const Icon = STATUS_ICONS[status]
const { bg, fg } = SOURCE_STATUS_COLORS[status]
return (
<Chip
size="small"
icon={<Icon size={11} color={fg} />}
label={SOURCE_STATUS_LABELS[status]}
sx={{
bgcolor: bg,
color: fg,
border: 'none',
fontSize: '0.7rem',
fontWeight: 600,
'& .MuiChip-icon': { ml: 0.75 },
}}
/>
)
}
+122
View File
@@ -0,0 +1,122 @@
import { Box, Chip, MenuItem, Select, TextField, Typography } from '@mui/material'
import { Search } from 'lucide-react'
import type { DataSource, SourceFilters } from '../../domain/dataSource'
import {
DataSourceType,
SourceStatus,
DATA_SOURCE_TYPE_LABELS,
SOURCE_STATUS_LABELS,
} from '../../domain/dataSource'
import { SourceCard } from './SourceCard'
import { MarketSignalSkeleton } from './MarketSignalSkeleton'
import { MarketSignalEmptyState } from './MarketSignalEmptyState'
interface SourceListProps {
sources: DataSource[]
isLoading: boolean
selectedId: string | null
onSelect: (id: string) => void
filters: SourceFilters
onFiltersChange: (f: SourceFilters) => void
}
export function SourceList({
sources,
isLoading,
selectedId,
onSelect,
filters,
onFiltersChange,
}: SourceListProps) {
const hasActiveFilters = filters.sourceType || filters.status || filters.search
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* 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 }}>
Datenquellen
</Typography>
<Chip
label={isLoading ? '…' : sources.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={{ px: 1.5, py: 1, borderBottom: '1px solid #f1f5f9', display: 'flex', gap: 1, flexShrink: 0 }}>
<Select
size="small"
displayEmpty
value={filters.sourceType ?? ''}
onChange={(e) => onFiltersChange({ ...filters, sourceType: (e.target.value as typeof DataSourceType[keyof typeof DataSourceType]) || undefined })}
sx={{ flex: 1, fontSize: '0.75rem', '& .MuiSelect-select': { py: 0.75 } }}
>
<MenuItem value=""><em>Alle Typen</em></MenuItem>
{Object.values(DataSourceType).map((t) => (
<MenuItem key={t} value={t} sx={{ fontSize: '0.8rem' }}>{DATA_SOURCE_TYPE_LABELS[t]}</MenuItem>
))}
</Select>
<Select
size="small"
displayEmpty
value={filters.status ?? ''}
onChange={(e) => onFiltersChange({ ...filters, status: (e.target.value as typeof SourceStatus[keyof typeof SourceStatus]) || undefined })}
sx={{ flex: 1, fontSize: '0.75rem', '& .MuiSelect-select': { py: 0.75 } }}
>
<MenuItem value=""><em>Alle Status</em></MenuItem>
{Object.values(SourceStatus).map((s) => (
<MenuItem key={s} value={s} sx={{ fontSize: '0.8rem' }}>{SOURCE_STATUS_LABELS[s]}</MenuItem>
))}
</Select>
</Box>
{hasActiveFilters && (
<Box sx={{ px: 1.5, py: 0.5, flexShrink: 0 }}>
<Chip
size="small"
label="Filter zurücksetzen"
onClick={() => onFiltersChange({})}
sx={{ fontSize: '0.7rem', cursor: 'pointer' }}
/>
</Box>
)}
{/* List */}
<Box sx={{ flex: 1, overflowY: 'auto' }}>
{isLoading ? (
Array.from({ length: 4 }).map((_, i) => <MarketSignalSkeleton key={i} />)
) : sources.length === 0 ? (
<MarketSignalEmptyState variant={hasActiveFilters ? 'no-results' : 'empty-inbox'} />
) : (
sources.map((source) => (
<SourceCard
key={source.id}
source={source}
selected={source.id === selectedId}
onClick={() => onSelect(source.id)}
/>
))
)}
</Box>
</Box>
)
}
+38
View File
@@ -0,0 +1,38 @@
import { Chip } from '@mui/material'
import { ShieldCheck, AlertTriangle, ShieldAlert, ShieldOff, HelpCircle } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type { TermsStatus } from '../../domain/dataSource'
import { TERMS_STATUS_LABELS, TERMS_STATUS_COLORS } from '../../domain/dataSource'
const TERMS_ICONS: Record<TermsStatus, LucideIcon> = {
APPROVED: ShieldCheck,
NEEDS_LEGAL_REVIEW: AlertTriangle,
RESTRICTED: ShieldAlert,
BLOCKED: ShieldOff,
UNKNOWN: HelpCircle,
}
interface TermsStatusBadgeProps {
status: TermsStatus
}
export function TermsStatusBadge({ status }: TermsStatusBadgeProps) {
const Icon = TERMS_ICONS[status]
const { bg, fg, border } = TERMS_STATUS_COLORS[status]
return (
<Chip
size="small"
icon={<Icon size={11} color={fg} />}
label={TERMS_STATUS_LABELS[status]}
sx={{
bgcolor: bg,
color: fg,
border: '1px solid',
borderColor: border,
fontSize: '0.7rem',
fontWeight: 600,
'& .MuiChip-icon': { ml: 0.75 },
}}
/>
)
}
+21
View File
@@ -0,0 +1,21 @@
export { SourceReliabilityBadge } from './SourceReliabilityBadge'
export { SignalConfidenceBadge } from './SignalConfidenceBadge'
export { SensitivityWarningPanel } from './SensitivityWarningPanel'
export { MarketSignalEmptyState } from './MarketSignalEmptyState'
export { MarketSignalSkeleton } from './MarketSignalSkeleton'
export { MarketSignalFilterBar } from './MarketSignalFilterBar'
export { MarketSignalCard } from './MarketSignalCard'
export { SignalEvidenceList } from './SignalEvidenceList'
export { SignalConversionPanel } from './SignalConversionPanel'
export { SignalInbox } from './SignalInbox'
export { MarketSignalDetailPanel } from './MarketSignalDetailPanel'
export { SourceHealthBadge } from './SourceHealthBadge'
export { TermsStatusBadge } from './TermsStatusBadge'
export { ReliabilityScorePanel } from './ReliabilityScorePanel'
export { DataCategoryBadgeList } from './DataCategoryBadgeList'
export { SourceErrorPanel } from './SourceErrorPanel'
export { SourceCard } from './SourceCard'
export { SourceList } from './SourceList'
export { ConnectorRunTable } from './ConnectorRunTable'
export { ConnectorRunDetailDrawer } from './ConnectorRunDetailDrawer'
export { SourceDetailPanel } from './SourceDetailPanel'