feat: F022 source acquisition, crawling & connector abstraction

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