feat: F022 source acquisition, crawling & connector abstraction
This commit is contained in:
@@ -30,6 +30,7 @@ import {
|
||||
Sparkles,
|
||||
Clock,
|
||||
Radar,
|
||||
ServerCog,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { OrganizationContextBadge } from './OrganizationContextBadge'
|
||||
@@ -100,6 +101,7 @@ const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
|
||||
{ path: '/ops/ai-monitoring', label: 'AI Monitoring', icon: Activity },
|
||||
{ path: '/ops/governance', label: 'Governance', icon: Shield },
|
||||
{ path: '/ops/market-intelligence', label: 'Market Intelligence', icon: Radar },
|
||||
{ path: '/ops/source-monitoring', label: 'Source Monitoring', icon: ServerCog },
|
||||
{ path: '/ops/activity-timeline', label: 'Aktivitäts-Timeline', icon: Clock },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 },
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 },
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user