feat: F022 source acquisition, crawling & connector abstraction
This commit is contained in:
@@ -22,6 +22,7 @@ const ReviewQueue = lazy(() => import('./pages/ops/ReviewQueue'))
|
||||
const AIMonitoring = lazy(() => import('./pages/ops/AIMonitoring'))
|
||||
const Governance = lazy(() => import('./pages/ops/Governance'))
|
||||
const MarketIntelligence = lazy(() => import('./pages/ops/MarketIntelligence'))
|
||||
const SourceMonitoring = lazy(() => import('./pages/ops/SourceMonitoring'))
|
||||
const ActivityTimeline = lazy(() => import('./pages/ops/ActivityTimeline'))
|
||||
|
||||
function App() {
|
||||
@@ -60,6 +61,7 @@ function App() {
|
||||
<Route path="/ops/ai-monitoring" element={<AIMonitoring />} />
|
||||
<Route path="/ops/governance" element={<Governance />} />
|
||||
<Route path="/ops/market-intelligence" element={<MarketIntelligence />} />
|
||||
<Route path="/ops/source-monitoring" element={<SourceMonitoring />} />
|
||||
<Route path="/ops/activity-timeline" element={<ActivityTimeline />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
@@ -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'
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { FreshnessStatus } from './enums'
|
||||
|
||||
// ── Connector / Source Type ───────────────────────────────────────────────────
|
||||
export const DataSourceType = {
|
||||
API_CONNECTOR: 'API_CONNECTOR',
|
||||
CSV_IMPORT: 'CSV_IMPORT',
|
||||
MANUAL_UPLOAD: 'MANUAL_UPLOAD',
|
||||
PUBLIC_WEB_SOURCE: 'PUBLIC_WEB_SOURCE',
|
||||
PARTNER_FEED: 'PARTNER_FEED',
|
||||
INTERNAL_PORTFOLIO_EXPORT: 'INTERNAL_PORTFOLIO_EXPORT',
|
||||
CONTRACT_METADATA_IMPORT: 'CONTRACT_METADATA_IMPORT',
|
||||
ANALYST_ENTRY: 'ANALYST_ENTRY',
|
||||
FUTURE_CRAWLER_STUB: 'FUTURE_CRAWLER_STUB',
|
||||
} as const
|
||||
export type DataSourceType = typeof DataSourceType[keyof typeof DataSourceType]
|
||||
|
||||
export const DATA_SOURCE_TYPE_LABELS: Record<DataSourceType, string> = {
|
||||
API_CONNECTOR: 'API-Connector',
|
||||
CSV_IMPORT: 'CSV-Import',
|
||||
MANUAL_UPLOAD: 'Manueller Upload',
|
||||
PUBLIC_WEB_SOURCE: 'Öffentliche Web-Quelle',
|
||||
PARTNER_FEED: 'Partner-Feed',
|
||||
INTERNAL_PORTFOLIO_EXPORT: 'Portfolio-Export',
|
||||
CONTRACT_METADATA_IMPORT: 'Vertrags-Import',
|
||||
ANALYST_ENTRY: 'Analysten-Eingabe',
|
||||
FUTURE_CRAWLER_STUB: 'Crawler (geplant)',
|
||||
}
|
||||
|
||||
// ── Source Status ─────────────────────────────────────────────────────────────
|
||||
export const SourceStatus = {
|
||||
ACTIVE: 'ACTIVE',
|
||||
PAUSED: 'PAUSED',
|
||||
ERROR: 'ERROR',
|
||||
PENDING_REVIEW: 'PENDING_REVIEW',
|
||||
DISABLED: 'DISABLED',
|
||||
} as const
|
||||
export type SourceStatus = typeof SourceStatus[keyof typeof SourceStatus]
|
||||
|
||||
export const SOURCE_STATUS_LABELS: Record<SourceStatus, string> = {
|
||||
ACTIVE: 'Aktiv',
|
||||
PAUSED: 'Pausiert',
|
||||
ERROR: 'Fehler',
|
||||
PENDING_REVIEW: 'Prüfung ausstehend',
|
||||
DISABLED: 'Deaktiviert',
|
||||
}
|
||||
|
||||
export const SOURCE_STATUS_COLORS: Record<SourceStatus, { bg: string; fg: string }> = {
|
||||
ACTIVE: { bg: 'rgba(22,163,74,0.1)', fg: '#15803d' },
|
||||
PAUSED: { bg: 'rgba(234,179,8,0.1)', fg: '#a16207' },
|
||||
ERROR: { bg: 'rgba(239,68,68,0.1)', fg: '#dc2626' },
|
||||
PENDING_REVIEW: { bg: 'rgba(59,130,246,0.1)', fg: '#1d4ed8' },
|
||||
DISABLED: { bg: 'rgba(148,163,184,0.1)', fg: '#64748b' },
|
||||
}
|
||||
|
||||
// ── Terms / Legal Status ──────────────────────────────────────────────────────
|
||||
export const TermsStatus = {
|
||||
APPROVED: 'APPROVED',
|
||||
NEEDS_LEGAL_REVIEW: 'NEEDS_LEGAL_REVIEW',
|
||||
RESTRICTED: 'RESTRICTED',
|
||||
BLOCKED: 'BLOCKED',
|
||||
UNKNOWN: 'UNKNOWN',
|
||||
} as const
|
||||
export type TermsStatus = typeof TermsStatus[keyof typeof TermsStatus]
|
||||
|
||||
export const TERMS_STATUS_LABELS: Record<TermsStatus, string> = {
|
||||
APPROVED: 'Genehmigt',
|
||||
NEEDS_LEGAL_REVIEW: 'Rechtliche Prüfung',
|
||||
RESTRICTED: 'Eingeschränkt',
|
||||
BLOCKED: 'Gesperrt',
|
||||
UNKNOWN: 'Unbekannt',
|
||||
}
|
||||
|
||||
export const TERMS_STATUS_COLORS: Record<TermsStatus, { bg: string; fg: string; border: string }> = {
|
||||
APPROVED: { bg: 'rgba(22,163,74,0.08)', fg: '#15803d', border: 'rgba(22,163,74,0.3)' },
|
||||
NEEDS_LEGAL_REVIEW: { bg: 'rgba(234,179,8,0.08)', fg: '#a16207', border: 'rgba(234,179,8,0.3)' },
|
||||
RESTRICTED: { bg: 'rgba(249,115,22,0.08)', fg: '#c2410c', border: 'rgba(249,115,22,0.3)' },
|
||||
BLOCKED: { bg: 'rgba(239,68,68,0.08)', fg: '#dc2626', border: 'rgba(239,68,68,0.3)' },
|
||||
UNKNOWN: { bg: 'rgba(148,163,184,0.08)', fg: '#64748b', border: 'rgba(148,163,184,0.3)' },
|
||||
}
|
||||
|
||||
// ── Connector Run Status ──────────────────────────────────────────────────────
|
||||
export const ConnectorRunStatus = {
|
||||
RUNNING: 'RUNNING',
|
||||
COMPLETED: 'COMPLETED',
|
||||
FAILED: 'FAILED',
|
||||
PARTIAL: 'PARTIAL',
|
||||
CANCELLED: 'CANCELLED',
|
||||
} as const
|
||||
export type ConnectorRunStatus = typeof ConnectorRunStatus[keyof typeof ConnectorRunStatus]
|
||||
|
||||
export const CONNECTOR_RUN_STATUS_LABELS: Record<ConnectorRunStatus, string> = {
|
||||
RUNNING: 'Läuft',
|
||||
COMPLETED: 'Abgeschlossen',
|
||||
FAILED: 'Fehlgeschlagen',
|
||||
PARTIAL: 'Teilweise',
|
||||
CANCELLED: 'Abgebrochen',
|
||||
}
|
||||
|
||||
export const CONNECTOR_RUN_STATUS_COLORS: Record<ConnectorRunStatus, { bg: string; fg: string }> = {
|
||||
RUNNING: { bg: 'rgba(99,102,241,0.1)', fg: '#4f46e5' },
|
||||
COMPLETED: { bg: 'rgba(22,163,74,0.1)', fg: '#15803d' },
|
||||
FAILED: { bg: 'rgba(239,68,68,0.1)', fg: '#dc2626' },
|
||||
PARTIAL: { bg: 'rgba(234,179,8,0.1)', fg: '#a16207' },
|
||||
CANCELLED: { bg: 'rgba(148,163,184,0.1)', fg: '#64748b' },
|
||||
}
|
||||
|
||||
// ── Interfaces ────────────────────────────────────────────────────────────────
|
||||
export interface DataSource {
|
||||
id: string
|
||||
name: string
|
||||
sourceType: DataSourceType
|
||||
ownerOrganizationId?: string
|
||||
legalBasis: string
|
||||
termsStatus: TermsStatus
|
||||
dataCategories: string[]
|
||||
supportedAssetTypes: string[]
|
||||
regionCoverage: string[]
|
||||
reliabilityScore: number
|
||||
freshnessStatus: FreshnessStatus
|
||||
lastRunAt?: string
|
||||
nextRunAt?: string
|
||||
status: SourceStatus
|
||||
errorState?: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface ConnectorRun {
|
||||
id: string
|
||||
sourceId: string
|
||||
startedAt: string
|
||||
finishedAt?: string
|
||||
status: ConnectorRunStatus
|
||||
itemsDetected: number
|
||||
itemsNormalized: number
|
||||
itemsRejected: number
|
||||
signalsCreated: number
|
||||
errors: string[]
|
||||
warnings: string[]
|
||||
runSummary: string
|
||||
}
|
||||
|
||||
export interface SourceFilters {
|
||||
search?: string
|
||||
sourceType?: DataSourceType
|
||||
status?: SourceStatus
|
||||
termsStatus?: TermsStatus
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { sourceService } from '../services/sourceService'
|
||||
import type { SourceFilters, SourceStatus, TermsStatus } from '../domain/dataSource'
|
||||
|
||||
const STALE_SOURCES = 30_000
|
||||
|
||||
export function useDataSources(filters?: SourceFilters) {
|
||||
return useQuery({
|
||||
queryKey: ['data-sources', filters ?? {}],
|
||||
queryFn: () => sourceService.getSources(filters),
|
||||
staleTime: STALE_SOURCES,
|
||||
select: (res) => res.data ?? [],
|
||||
})
|
||||
}
|
||||
|
||||
export function useDataSource(id: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['data-source', id],
|
||||
queryFn: () => sourceService.getSource(id!),
|
||||
enabled: id !== null,
|
||||
staleTime: STALE_SOURCES,
|
||||
select: (res) => res.data ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export function useConnectorRuns(sourceId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['connector-runs', sourceId],
|
||||
queryFn: () => sourceService.getConnectorRuns(sourceId!),
|
||||
enabled: sourceId !== null,
|
||||
staleTime: STALE_SOURCES,
|
||||
select: (res) => res.data ?? [],
|
||||
})
|
||||
}
|
||||
|
||||
export function useTriggerMockRun() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (sourceId: string) => sourceService.triggerMockRun(sourceId),
|
||||
onSuccess: (_data, sourceId) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['data-sources'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['data-source', sourceId] })
|
||||
queryClient.invalidateQueries({ queryKey: ['connector-runs', sourceId] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateSourceStatus() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: SourceStatus }) =>
|
||||
sourceService.updateSourceStatus(id, status),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['data-sources'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['data-source'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useMarkTermsStatus() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, termsStatus }: { id: string; termsStatus: TermsStatus }) =>
|
||||
sourceService.markTermsStatus(id, termsStatus),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['data-sources'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['data-source'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import type { DataSource, ConnectorRun } from '../domain/dataSource'
|
||||
import {
|
||||
DataSourceType,
|
||||
SourceStatus,
|
||||
TermsStatus,
|
||||
ConnectorRunStatus,
|
||||
} from '../domain/dataSource'
|
||||
import { FreshnessStatus } from '../domain/enums'
|
||||
|
||||
export const MOCK_DATA_SOURCES: DataSource[] = [
|
||||
{
|
||||
id: 'src-001',
|
||||
name: 'ImmoScout24 – Zürich/Bern Feed',
|
||||
sourceType: DataSourceType.PUBLIC_WEB_SOURCE,
|
||||
legalBasis: 'Öffentlich zugängliche Listings – keine personenbezogenen Daten',
|
||||
termsStatus: TermsStatus.NEEDS_LEGAL_REVIEW,
|
||||
dataCategories: ['Gewerbeimmobilien', 'Mietpreise', 'Verfügbarkeit'],
|
||||
supportedAssetTypes: ['OFFICE', 'RETAIL', 'LIGHT_INDUSTRIAL'],
|
||||
regionCoverage: ['Zürich', 'Bern', 'Basel'],
|
||||
reliabilityScore: 0.72,
|
||||
freshnessStatus: FreshnessStatus.FRESH,
|
||||
lastRunAt: '2026-05-15T06:00:00Z',
|
||||
nextRunAt: '2026-05-16T06:00:00Z',
|
||||
status: SourceStatus.ACTIVE,
|
||||
notes: 'Daily refresh via FUTURE_CRAWLER_STUB. Rechtliche Freigabe ausstehend.',
|
||||
},
|
||||
{
|
||||
id: 'src-002',
|
||||
name: 'Ideal Sharing Portfolio Export',
|
||||
sourceType: DataSourceType.INTERNAL_PORTFOLIO_EXPORT,
|
||||
ownerOrganizationId: 'org-001',
|
||||
legalBasis: 'Internes Dateneigentum – vollständig genehmigt',
|
||||
termsStatus: TermsStatus.APPROVED,
|
||||
dataCategories: ['Portoflio', 'Mietverträge', 'Flächen'],
|
||||
supportedAssetTypes: ['OFFICE', 'LOGISTICS', 'MIXED'],
|
||||
regionCoverage: ['Zürich', 'Zug', 'Luzern'],
|
||||
reliabilityScore: 0.97,
|
||||
freshnessStatus: FreshnessStatus.FRESH,
|
||||
lastRunAt: '2026-05-15T02:00:00Z',
|
||||
nextRunAt: '2026-05-15T14:00:00Z',
|
||||
status: SourceStatus.ACTIVE,
|
||||
},
|
||||
{
|
||||
id: 'src-003',
|
||||
name: 'CBRE Market Data API',
|
||||
sourceType: DataSourceType.API_CONNECTOR,
|
||||
ownerOrganizationId: 'org-002',
|
||||
legalBasis: 'API-Lizenzvertrag mit CBRE vom 12.01.2026',
|
||||
termsStatus: TermsStatus.APPROVED,
|
||||
dataCategories: ['Marktdaten', 'Mietindizes', 'Transaktionsvolumen'],
|
||||
supportedAssetTypes: ['OFFICE', 'RETAIL', 'LOGISTICS'],
|
||||
regionCoverage: ['Schweiz', 'DACH'],
|
||||
reliabilityScore: 0.91,
|
||||
freshnessStatus: FreshnessStatus.STALE,
|
||||
lastRunAt: '2026-05-13T10:00:00Z',
|
||||
nextRunAt: '2026-05-17T10:00:00Z',
|
||||
status: SourceStatus.ACTIVE,
|
||||
notes: 'Wöchentlicher Refresh – Quarterly Report verfügbar.',
|
||||
},
|
||||
{
|
||||
id: 'src-004',
|
||||
name: 'Handelsregister CH – Firmenumzüge',
|
||||
sourceType: DataSourceType.PUBLIC_WEB_SOURCE,
|
||||
legalBasis: 'Öffentliches Staatsregister (SHAB)',
|
||||
termsStatus: TermsStatus.APPROVED,
|
||||
dataCategories: ['Firmensitze', 'Umzüge', 'Neugründungen'],
|
||||
supportedAssetTypes: ['OFFICE', 'MIXED'],
|
||||
regionCoverage: ['Schweiz'],
|
||||
reliabilityScore: 0.88,
|
||||
freshnessStatus: FreshnessStatus.FRESH,
|
||||
lastRunAt: '2026-05-15T03:30:00Z',
|
||||
nextRunAt: '2026-05-16T03:30:00Z',
|
||||
status: SourceStatus.ACTIVE,
|
||||
},
|
||||
{
|
||||
id: 'src-005',
|
||||
name: 'Baubewilligungsregister Kt. Zürich',
|
||||
sourceType: DataSourceType.FUTURE_CRAWLER_STUB,
|
||||
legalBasis: 'Amtliche Publikation – öffentlich zugänglich',
|
||||
termsStatus: TermsStatus.UNKNOWN,
|
||||
dataCategories: ['Baubewilligungen', 'Umnutzungen', 'Abbrüche'],
|
||||
supportedAssetTypes: ['OFFICE', 'PRODUCTION', 'LIGHT_INDUSTRIAL'],
|
||||
regionCoverage: ['Kanton Zürich'],
|
||||
reliabilityScore: 0.65,
|
||||
freshnessStatus: FreshnessStatus.OUTDATED,
|
||||
lastRunAt: '2026-04-30T08:00:00Z',
|
||||
status: SourceStatus.PENDING_REVIEW,
|
||||
notes: 'Crawler-Implementierung noch ausstehend. Manuelle Prüfung erforderlich.',
|
||||
},
|
||||
{
|
||||
id: 'src-006',
|
||||
name: 'Mietvertragsdaten (CSV Upload – Q1 2026)',
|
||||
sourceType: DataSourceType.CSV_IMPORT,
|
||||
ownerOrganizationId: 'org-001',
|
||||
legalBasis: 'Interner Upload durch Portfoliomanager – DSGVO-konform',
|
||||
termsStatus: TermsStatus.APPROVED,
|
||||
dataCategories: ['Mietverträge', 'Laufzeiten', 'Mieter'],
|
||||
supportedAssetTypes: ['OFFICE', 'RETAIL'],
|
||||
regionCoverage: ['Zürich', 'Basel'],
|
||||
reliabilityScore: 0.84,
|
||||
freshnessStatus: FreshnessStatus.STALE,
|
||||
lastRunAt: '2026-03-31T09:00:00Z',
|
||||
status: SourceStatus.PAUSED,
|
||||
notes: 'Q2-Upload ausstehend. Quelle pausiert bis neue Datei verfügbar.',
|
||||
},
|
||||
{
|
||||
id: 'src-007',
|
||||
name: 'JLL Research Partner Feed',
|
||||
sourceType: DataSourceType.PARTNER_FEED,
|
||||
ownerOrganizationId: 'org-003',
|
||||
legalBasis: 'Datenaustauschabkommen mit JLL Schweiz AG – vertraulich',
|
||||
termsStatus: TermsStatus.RESTRICTED,
|
||||
dataCategories: ['Marktberichte', 'Leerstandsquoten', 'Prime Rents'],
|
||||
supportedAssetTypes: ['OFFICE', 'LOGISTICS'],
|
||||
regionCoverage: ['Zürich', 'Genf', 'Basel'],
|
||||
reliabilityScore: 0.93,
|
||||
freshnessStatus: FreshnessStatus.FRESH,
|
||||
lastRunAt: '2026-05-15T07:00:00Z',
|
||||
nextRunAt: '2026-05-22T07:00:00Z',
|
||||
status: SourceStatus.ACTIVE,
|
||||
notes: 'Wöchentlicher Push durch JLL. Nur für interne Nutzung – kein Re-Export.',
|
||||
},
|
||||
{
|
||||
id: 'src-008',
|
||||
name: 'Analyst-Eingaben (Team Zürich)',
|
||||
sourceType: DataSourceType.ANALYST_ENTRY,
|
||||
ownerOrganizationId: 'org-001',
|
||||
legalBasis: 'Interne Datenerhebung durch Analysten-Team',
|
||||
termsStatus: TermsStatus.APPROVED,
|
||||
dataCategories: ['Marktsignale', 'Qualitative Einschätzungen', 'Netzwerkinfos'],
|
||||
supportedAssetTypes: ['OFFICE', 'RETAIL', 'MIXED'],
|
||||
regionCoverage: ['Zürich', 'Winterthur'],
|
||||
reliabilityScore: 0.78,
|
||||
freshnessStatus: FreshnessStatus.FRESH,
|
||||
lastRunAt: '2026-05-14T16:45:00Z',
|
||||
status: SourceStatus.ACTIVE,
|
||||
},
|
||||
{
|
||||
id: 'src-009',
|
||||
name: 'Homegate Gewerbe-Scraper (Beta)',
|
||||
sourceType: DataSourceType.FUTURE_CRAWLER_STUB,
|
||||
legalBasis: 'In rechtlicher Prüfung – noch nicht freigegeben',
|
||||
termsStatus: TermsStatus.BLOCKED,
|
||||
dataCategories: ['Gewerbelistings', 'Mietpreise'],
|
||||
supportedAssetTypes: ['OFFICE', 'RETAIL'],
|
||||
regionCoverage: ['Schweiz'],
|
||||
reliabilityScore: 0.0,
|
||||
freshnessStatus: FreshnessStatus.OUTDATED,
|
||||
status: SourceStatus.DISABLED,
|
||||
errorState: 'Quelle gesperrt – robots.txt-Prüfung negativ, AGB untersagt automatisiertes Crawling.',
|
||||
},
|
||||
]
|
||||
|
||||
export const MOCK_CONNECTOR_RUNS: ConnectorRun[] = [
|
||||
// src-001 runs
|
||||
{
|
||||
id: 'run-001-a',
|
||||
sourceId: 'src-001',
|
||||
startedAt: '2026-05-15T06:00:00Z',
|
||||
finishedAt: '2026-05-15T06:12:34Z',
|
||||
status: ConnectorRunStatus.COMPLETED,
|
||||
itemsDetected: 342,
|
||||
itemsNormalized: 318,
|
||||
itemsRejected: 24,
|
||||
signalsCreated: 7,
|
||||
errors: [],
|
||||
warnings: ['24 Einträge ohne gültige Flächenangabe übersprungen'],
|
||||
runSummary: '342 Listings importiert. 318 normalisiert, 7 neue Marktsignale erkannt.',
|
||||
},
|
||||
{
|
||||
id: 'run-001-b',
|
||||
sourceId: 'src-001',
|
||||
startedAt: '2026-05-14T06:00:00Z',
|
||||
finishedAt: '2026-05-14T06:09:11Z',
|
||||
status: ConnectorRunStatus.COMPLETED,
|
||||
itemsDetected: 289,
|
||||
itemsNormalized: 277,
|
||||
itemsRejected: 12,
|
||||
signalsCreated: 4,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
runSummary: '289 Listings importiert. 4 neue Marktsignale erkannt.',
|
||||
},
|
||||
{
|
||||
id: 'run-001-c',
|
||||
sourceId: 'src-001',
|
||||
startedAt: '2026-05-13T06:00:00Z',
|
||||
finishedAt: '2026-05-13T06:04:22Z',
|
||||
status: ConnectorRunStatus.PARTIAL,
|
||||
itemsDetected: 310,
|
||||
itemsNormalized: 201,
|
||||
itemsRejected: 109,
|
||||
signalsCreated: 2,
|
||||
errors: ['HTTP 429 nach 201 Einträgen – Rate Limit erreicht'],
|
||||
warnings: ['109 Einträge konnten nicht abgerufen werden'],
|
||||
runSummary: 'Teilimport wegen Rate Limiting. 201 von 310 Einträgen verarbeitet.',
|
||||
},
|
||||
// src-002 runs
|
||||
{
|
||||
id: 'run-002-a',
|
||||
sourceId: 'src-002',
|
||||
startedAt: '2026-05-15T02:00:00Z',
|
||||
finishedAt: '2026-05-15T02:03:08Z',
|
||||
status: ConnectorRunStatus.COMPLETED,
|
||||
itemsDetected: 1240,
|
||||
itemsNormalized: 1240,
|
||||
itemsRejected: 0,
|
||||
signalsCreated: 12,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
runSummary: 'Vollständiger Portfolio-Sync. 12 Lease-Expiry-Signale erzeugt.',
|
||||
},
|
||||
{
|
||||
id: 'run-002-b',
|
||||
sourceId: 'src-002',
|
||||
startedAt: '2026-05-14T14:00:00Z',
|
||||
finishedAt: '2026-05-14T14:02:55Z',
|
||||
status: ConnectorRunStatus.COMPLETED,
|
||||
itemsDetected: 1238,
|
||||
itemsNormalized: 1238,
|
||||
itemsRejected: 0,
|
||||
signalsCreated: 3,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
runSummary: 'Delta-Sync erfolgreich. 3 neue Objekte hinzugefügt.',
|
||||
},
|
||||
// src-003 runs
|
||||
{
|
||||
id: 'run-003-a',
|
||||
sourceId: 'src-003',
|
||||
startedAt: '2026-05-13T10:00:00Z',
|
||||
finishedAt: '2026-05-13T10:18:42Z',
|
||||
status: ConnectorRunStatus.COMPLETED,
|
||||
itemsDetected: 86,
|
||||
itemsNormalized: 86,
|
||||
itemsRejected: 0,
|
||||
signalsCreated: 0,
|
||||
errors: [],
|
||||
warnings: ['Quartalsdaten verfügbar – manueller Review empfohlen'],
|
||||
runSummary: 'Marktdaten-Update Q1 2026 importiert. 86 Datenpunkte aktualisiert.',
|
||||
},
|
||||
// src-004 runs
|
||||
{
|
||||
id: 'run-004-a',
|
||||
sourceId: 'src-004',
|
||||
startedAt: '2026-05-15T03:30:00Z',
|
||||
finishedAt: '2026-05-15T03:44:17Z',
|
||||
status: ConnectorRunStatus.COMPLETED,
|
||||
itemsDetected: 523,
|
||||
itemsNormalized: 498,
|
||||
itemsRejected: 25,
|
||||
signalsCreated: 9,
|
||||
errors: [],
|
||||
warnings: ['25 Einträge ohne Adressangabe ignoriert'],
|
||||
runSummary: '523 Handelsregistereinträge geprüft. 9 Firmensitz-Signale erkannt.',
|
||||
},
|
||||
// src-007 runs
|
||||
{
|
||||
id: 'run-007-a',
|
||||
sourceId: 'src-007',
|
||||
startedAt: '2026-05-15T07:00:00Z',
|
||||
finishedAt: '2026-05-15T07:05:30Z',
|
||||
status: ConnectorRunStatus.COMPLETED,
|
||||
itemsDetected: 14,
|
||||
itemsNormalized: 14,
|
||||
itemsRejected: 0,
|
||||
signalsCreated: 3,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
runSummary: 'JLL Weekly Report verarbeitet. 3 Marktberichte als Signale importiert.',
|
||||
},
|
||||
// src-008 runs
|
||||
{
|
||||
id: 'run-008-a',
|
||||
sourceId: 'src-008',
|
||||
startedAt: '2026-05-14T16:45:00Z',
|
||||
finishedAt: '2026-05-14T16:45:52Z',
|
||||
status: ConnectorRunStatus.COMPLETED,
|
||||
itemsDetected: 3,
|
||||
itemsNormalized: 3,
|
||||
itemsRejected: 0,
|
||||
signalsCreated: 3,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
runSummary: '3 manuelle Analysten-Einträge verarbeitet. Je 1 Signal pro Eintrag.',
|
||||
},
|
||||
// src-006 – last run before pause
|
||||
{
|
||||
id: 'run-006-a',
|
||||
sourceId: 'src-006',
|
||||
startedAt: '2026-03-31T09:00:00Z',
|
||||
finishedAt: '2026-03-31T09:02:19Z',
|
||||
status: ConnectorRunStatus.COMPLETED,
|
||||
itemsDetected: 412,
|
||||
itemsNormalized: 408,
|
||||
itemsRejected: 4,
|
||||
signalsCreated: 18,
|
||||
errors: [],
|
||||
warnings: ['4 Einträge mit doppelter Vertrags-ID ignoriert'],
|
||||
runSummary: 'Q1 CSV verarbeitet. 408 Mietverträge importiert, 18 Lease-Expiry-Signale.',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState } from 'react'
|
||||
import { Box } from '@mui/material'
|
||||
import { PageHeader } from '../../components/layout'
|
||||
import { SourceList, SourceDetailPanel } from '../../components/ops'
|
||||
import { useDataSources, useDataSource } from '../../hooks/useDataSources'
|
||||
import type { SourceFilters } from '../../domain/dataSource'
|
||||
|
||||
export default function SourceMonitoring() {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [filters, setFilters] = useState<SourceFilters>({})
|
||||
|
||||
const { data: sources = [], isLoading } = useDataSources(filters)
|
||||
const { data: selectedSource = null } = useDataSource(selectedId)
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<PageHeader
|
||||
title="Source Monitoring"
|
||||
subtitle="Datenquellen, Connectoren und Import-Runs verwalten"
|
||||
/>
|
||||
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
{/* Left: Source list — fixed 400px */}
|
||||
<Box
|
||||
sx={{
|
||||
width: 400,
|
||||
flexShrink: 0,
|
||||
borderRight: '1px solid #e2e8f0',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<SourceList
|
||||
sources={sources}
|
||||
isLoading={isLoading}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
/>
|
||||
</Box>
|
||||
{/* Right: Detail panel */}
|
||||
<Box sx={{ flex: 1, overflow: 'hidden' }}>
|
||||
<SourceDetailPanel source={selectedSource} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { DataSource, ConnectorRun, SourceStatus, TermsStatus, SourceFilters } from '../domain/dataSource'
|
||||
|
||||
export interface IDataSourceProvider {
|
||||
getSources(filters?: SourceFilters): Promise<DataSource[]>
|
||||
getSource(id: string): Promise<DataSource | null>
|
||||
getConnectorRuns(sourceId: string): Promise<ConnectorRun[]>
|
||||
triggerMockRun(sourceId: string): Promise<ConnectorRun>
|
||||
updateSourceStatus(id: string, status: SourceStatus): Promise<DataSource>
|
||||
markTermsStatus(id: string, termsStatus: TermsStatus): Promise<DataSource>
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { IDataSourceProvider } from './IDataSourceProvider'
|
||||
import type { DataSource, ConnectorRun, SourceStatus, TermsStatus, SourceFilters } from '../domain/dataSource'
|
||||
import { ConnectorRunStatus } from '../domain/dataSource'
|
||||
import { MOCK_DATA_SOURCES, MOCK_CONNECTOR_RUNS } from '../mock-data/dataSources'
|
||||
|
||||
let sources: DataSource[] = [...MOCK_DATA_SOURCES]
|
||||
let runs: ConnectorRun[] = [...MOCK_CONNECTOR_RUNS]
|
||||
|
||||
function applyFilters(items: DataSource[], filters?: SourceFilters): DataSource[] {
|
||||
if (!filters) return items
|
||||
return items.filter((s) => {
|
||||
if (filters.sourceType && s.sourceType !== filters.sourceType) return false
|
||||
if (filters.status && s.status !== filters.status) return false
|
||||
if (filters.termsStatus && s.termsStatus !== filters.termsStatus) return false
|
||||
if (filters.search) {
|
||||
const q = filters.search.toLowerCase()
|
||||
if (!s.name.toLowerCase().includes(q) && !s.legalBasis.toLowerCase().includes(q)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export const MockupDataSourceProvider: IDataSourceProvider = {
|
||||
async getSources(filters?: SourceFilters) {
|
||||
return applyFilters([...sources], filters)
|
||||
},
|
||||
|
||||
async getSource(id: string) {
|
||||
return sources.find((s) => s.id === id) ?? null
|
||||
},
|
||||
|
||||
async getConnectorRuns(sourceId: string) {
|
||||
return runs
|
||||
.filter((r) => r.sourceId === sourceId)
|
||||
.sort((a, b) => b.startedAt.localeCompare(a.startedAt))
|
||||
},
|
||||
|
||||
async triggerMockRun(sourceId: string) {
|
||||
const source = sources.find((s) => s.id === sourceId)
|
||||
const now = new Date().toISOString()
|
||||
const newRun: ConnectorRun = {
|
||||
id: `run-${crypto.randomUUID().slice(0, 8)}`,
|
||||
sourceId,
|
||||
startedAt: now,
|
||||
finishedAt: now,
|
||||
status: ConnectorRunStatus.COMPLETED,
|
||||
itemsDetected: Math.floor(Math.random() * 200) + 50,
|
||||
itemsNormalized: Math.floor(Math.random() * 180) + 40,
|
||||
itemsRejected: Math.floor(Math.random() * 15),
|
||||
signalsCreated: Math.floor(Math.random() * 8) + 1,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
runSummary: `Demo-Run für ${source?.name ?? sourceId} erfolgreich abgeschlossen.`,
|
||||
}
|
||||
runs = [newRun, ...runs]
|
||||
sources = sources.map((s) =>
|
||||
s.id === sourceId ? { ...s, lastRunAt: now } : s
|
||||
)
|
||||
return newRun
|
||||
},
|
||||
|
||||
async updateSourceStatus(id: string, status: SourceStatus) {
|
||||
const idx = sources.findIndex((s) => s.id === id)
|
||||
if (idx === -1) throw new Error(`Source ${id} not found`)
|
||||
sources[idx] = { ...sources[idx], status }
|
||||
return sources[idx]
|
||||
},
|
||||
|
||||
async markTermsStatus(id: string, termsStatus: TermsStatus) {
|
||||
const idx = sources.findIndex((s) => s.id === id)
|
||||
if (idx === -1) throw new Error(`Source ${id} not found`)
|
||||
sources[idx] = { ...sources[idx], termsStatus }
|
||||
return sources[idx]
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { MockupDataSourceProvider } from '../provider/MockupDataSourceProvider'
|
||||
import type { DataSource, ConnectorRun, SourceStatus, TermsStatus, SourceFilters } from '../domain/dataSource'
|
||||
import type { ListResponse, ItemResponse } from './types'
|
||||
|
||||
const provider = MockupDataSourceProvider
|
||||
|
||||
export const sourceService = {
|
||||
async getSources(filters?: SourceFilters): Promise<ListResponse<DataSource>> {
|
||||
const data = await provider.getSources(filters)
|
||||
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||
},
|
||||
|
||||
async getSource(id: string): Promise<ItemResponse<DataSource | null>> {
|
||||
const data = await provider.getSource(id)
|
||||
return { data }
|
||||
},
|
||||
|
||||
async getConnectorRuns(sourceId: string): Promise<ListResponse<ConnectorRun>> {
|
||||
const data = await provider.getConnectorRuns(sourceId)
|
||||
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||
},
|
||||
|
||||
async triggerMockRun(sourceId: string): Promise<ItemResponse<ConnectorRun>> {
|
||||
const data = await provider.triggerMockRun(sourceId)
|
||||
return { data }
|
||||
},
|
||||
|
||||
async updateSourceStatus(id: string, status: SourceStatus): Promise<ItemResponse<DataSource>> {
|
||||
const data = await provider.updateSourceStatus(id, status)
|
||||
return { data }
|
||||
},
|
||||
|
||||
async markTermsStatus(id: string, termsStatus: TermsStatus): Promise<ItemResponse<DataSource>> {
|
||||
const data = await provider.markTermsStatus(id, termsStatus)
|
||||
return { data }
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user