diff --git a/src/App.tsx b/src/App.tsx
index 72fd579..4932b45 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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() {
} />
} />
} />
+ } />
} />
diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx
index ac851a7..3dcf5f8 100644
--- a/src/components/layout/AppShell.tsx
+++ b/src/components/layout/AppShell.tsx
@@ -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 = {
{ 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 },
],
},
diff --git a/src/components/ops/ConnectorRunDetailDrawer.tsx b/src/components/ops/ConnectorRunDetailDrawer.tsx
new file mode 100644
index 0000000..8bb3635
--- /dev/null
+++ b/src/components/ops/ConnectorRunDetailDrawer.tsx
@@ -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 (
+
+ {run && (
+
+ {/* Header */}
+
+
+ Import-Run Details
+
+
+
+
+
+
+
+ {/* Status */}
+
+
+
+ {run.id}
+
+
+
+ {/* Timestamps */}
+
+
+ Gestartet: {formatDate(run.startedAt)}
+
+ {run.finishedAt && (
+
+ Beendet: {formatDate(run.finishedAt)}
+
+ )}
+
+
+
+
+ {/* Stats grid */}
+
+ {[
+ { 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 }) => (
+
+
+ {value}
+
+ {label}
+
+ ))}
+
+
+ {/* Summary */}
+
+
+ Zusammenfassung
+
+
+ {run.runSummary}
+
+
+ {/* Signals note */}
+ {run.signalsCreated > 0 && (
+
+
+
+ {run.signalsCreated} Marktsignal{run.signalsCreated !== 1 ? 'e' : ''} aus diesem Run verfügbar in Market Intelligence.
+
+
+ )}
+
+ {/* Errors */}
+ {run.errors.length > 0 && (
+ <>
+
+ Fehler
+
+
+ {run.errors.map((err, i) => (
+
+
+ {err}
+
+ ))}
+
+ >
+ )}
+
+ {/* Warnings */}
+ {run.warnings.length > 0 && (
+ <>
+
+ Warnungen
+
+
+ {run.warnings.map((w, i) => (
+
+
+ {w}
+
+ ))}
+
+ >
+ )}
+
+
+ )}
+
+ )
+}
diff --git a/src/components/ops/ConnectorRunTable.tsx b/src/components/ops/ConnectorRunTable.tsx
new file mode 100644
index 0000000..649406a
--- /dev/null
+++ b/src/components/ops/ConnectorRunTable.tsx
@@ -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 (
+
+ Keine Import-Runs vorhanden.
+
+ )
+ }
+
+ return (
+
+
+
+
+ Gestartet
+ Status
+ Erkannt
+ Normalisiert
+ Signale
+ Dauer
+
+
+
+ {runs.map((run) => {
+ const { bg, fg } = CONNECTOR_RUN_STATUS_COLORS[run.status]
+ return (
+ onSelectRun(run)}
+ sx={{ cursor: 'pointer', '& td': { fontSize: '0.78rem', py: 0.75 } }}
+ >
+ {formatDate(run.startedAt)}
+
+
+
+ {run.itemsDetected}
+ {run.itemsNormalized}
+
+ 0 ? '#7c3aed' : 'text.primary', fontWeight: run.signalsCreated > 0 ? 600 : 400 }}>
+ {run.signalsCreated}
+
+
+
+ {formatDuration(run.startedAt, run.finishedAt)}
+
+
+ )
+ })}
+
+
+
+ )
+}
diff --git a/src/components/ops/DataCategoryBadgeList.tsx b/src/components/ops/DataCategoryBadgeList.tsx
new file mode 100644
index 0000000..be25c05
--- /dev/null
+++ b/src/components/ops/DataCategoryBadgeList.tsx
@@ -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 (
+
+ {visible.map((cat) => (
+
+ ))}
+ {overflow > 0 && (
+
+ )}
+
+ )
+}
diff --git a/src/components/ops/ReliabilityScorePanel.tsx b/src/components/ops/ReliabilityScorePanel.tsx
new file mode 100644
index 0000000..efade0f
--- /dev/null
+++ b/src/components/ops/ReliabilityScorePanel.tsx
@@ -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 (
+
+
+
+ {pct}%
+
+
+ )
+ }
+
+ return (
+
+
+
+ Source Reliability
+
+
+ {pct}%
+
+
+
+
+ )
+}
diff --git a/src/components/ops/SourceCard.tsx b/src/components/ops/SourceCard.tsx
new file mode 100644
index 0000000..39f31a1
--- /dev/null
+++ b/src/components/ops/SourceCard.tsx
@@ -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 = {
+ 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 (
+
+
+
+
+
+
+
+ {source.name}
+
+
+ {DATA_SOURCE_TYPE_LABELS[source.sourceType]}
+
+
+
+
+
+
+
+
+
+
+
+
+ {formatDate(source.lastRunAt)}
+
+
+
+ {source.regionCoverage.length > 0 && (
+
+ {source.regionCoverage.slice(0, 3).map((r) => (
+
+ ))}
+ {source.regionCoverage.length > 3 && (
+
+ +{source.regionCoverage.length - 3}
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/src/components/ops/SourceDetailPanel.tsx b/src/components/ops/SourceDetailPanel.tsx
new file mode 100644
index 0000000..c0511e2
--- /dev/null
+++ b/src/components/ops/SourceDetailPanel.tsx
@@ -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 = {
+ 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(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
+
+ 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 (
+
+ {/* Header */}
+
+
+
+
+
+
+
+ {source.name}
+
+
+ {DATA_SOURCE_TYPE_LABELS[source.sourceType]}
+ {source.ownerOrganizationId && ` · Org ${source.ownerOrganizationId}`}
+
+
+
+
+
+
+
+
+
+
+
+ {/* Body */}
+
+ {source.errorState && }
+
+ {/* Legal basis */}
+
+
+ Rechtliche Grundlage
+
+
+ {source.legalBasis}
+
+
+
+ {/* Reliability */}
+
+
+
+
+ {/* Run timestamps */}
+
+
+ Letzter Run
+ {formatDate(source.lastRunAt)}
+
+
+ Nächster Run
+ {formatDate(source.nextRunAt)}
+
+
+
+
+
+ {/* Data categories */}
+ Datenkategorien
+
+
+
+
+ {/* Region coverage */}
+ Regionen
+
+ {source.regionCoverage.map((r) => (
+
+ ))}
+
+
+ {/* Asset types */}
+ Asset-Typen
+
+ {source.supportedAssetTypes.map((a) => (
+
+ ))}
+
+
+ {source.notes && (
+ <>
+
+ Notizen
+
+ {source.notes}
+
+ >
+ )}
+
+ {/* Actions */}
+ {isActionable && (
+ <>
+
+ Aktionen
+
+ {canRun && (
+ : }
+ disabled={isRunning}
+ onClick={() => triggerRun(source.id)}
+ sx={{ bgcolor: '#1e3a5f', textTransform: 'none', fontSize: '0.8rem', '&:hover': { bgcolor: '#162d4a' } }}
+ >
+ {isRunning ? 'Läuft...' : 'Demo-Run starten'}
+
+ )}
+ {source.status === SourceStatus.ACTIVE && (
+ }
+ 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
+
+ )}
+ {source.status === SourceStatus.PAUSED && (
+ }
+ 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
+
+ )}
+ {source.termsStatus !== TermsStatus.NEEDS_LEGAL_REVIEW && (
+
+ }
+ 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
+
+
+ )}
+
+ }
+ sx={{ textTransform: 'none', fontSize: '0.8rem', color: 'text.secondary', borderColor: 'divider' }}
+ >
+ Signale ansehen
+
+
+
+ >
+ )}
+
+ {/* Connector runs */}
+
+
+ Import-Runs
+
+
+
+
+ setSelectedRun(null)} />
+
+ )
+}
diff --git a/src/components/ops/SourceErrorPanel.tsx b/src/components/ops/SourceErrorPanel.tsx
new file mode 100644
index 0000000..839e11a
--- /dev/null
+++ b/src/components/ops/SourceErrorPanel.tsx
@@ -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 (
+ }
+ sx={{ mb: 2, fontSize: '0.8rem', '& .MuiAlert-message': { width: '100%' } }}
+ >
+
+ Fehlerzustand
+
+
+ {errorState}
+
+
+ )
+}
diff --git a/src/components/ops/SourceHealthBadge.tsx b/src/components/ops/SourceHealthBadge.tsx
new file mode 100644
index 0000000..e74384c
--- /dev/null
+++ b/src/components/ops/SourceHealthBadge.tsx
@@ -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 = {
+ 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 (
+ }
+ label={SOURCE_STATUS_LABELS[status]}
+ sx={{
+ bgcolor: bg,
+ color: fg,
+ border: 'none',
+ fontSize: '0.7rem',
+ fontWeight: 600,
+ '& .MuiChip-icon': { ml: 0.75 },
+ }}
+ />
+ )
+}
diff --git a/src/components/ops/SourceList.tsx b/src/components/ops/SourceList.tsx
new file mode 100644
index 0000000..8e30956
--- /dev/null
+++ b/src/components/ops/SourceList.tsx
@@ -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 (
+
+ {/* Header */}
+
+
+ Datenquellen
+
+
+
+
+ {/* Search */}
+
+ onFiltersChange({ ...filters, search: e.target.value || undefined })}
+ slotProps={{
+ input: {
+ startAdornment: ,
+ },
+ }}
+ sx={{ '& .MuiOutlinedInput-root': { fontSize: '0.8rem' } }}
+ />
+
+
+ {/* Filters */}
+
+
+
+
+
+ {hasActiveFilters && (
+
+ onFiltersChange({})}
+ sx={{ fontSize: '0.7rem', cursor: 'pointer' }}
+ />
+
+ )}
+
+ {/* List */}
+
+ {isLoading ? (
+ Array.from({ length: 4 }).map((_, i) => )
+ ) : sources.length === 0 ? (
+
+ ) : (
+ sources.map((source) => (
+ onSelect(source.id)}
+ />
+ ))
+ )}
+
+
+ )
+}
diff --git a/src/components/ops/TermsStatusBadge.tsx b/src/components/ops/TermsStatusBadge.tsx
new file mode 100644
index 0000000..2851b30
--- /dev/null
+++ b/src/components/ops/TermsStatusBadge.tsx
@@ -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 = {
+ 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 (
+ }
+ 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 },
+ }}
+ />
+ )
+}
diff --git a/src/components/ops/index.ts b/src/components/ops/index.ts
new file mode 100644
index 0000000..19335f4
--- /dev/null
+++ b/src/components/ops/index.ts
@@ -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'
diff --git a/src/domain/dataSource.ts b/src/domain/dataSource.ts
new file mode 100644
index 0000000..a0fbd41
--- /dev/null
+++ b/src/domain/dataSource.ts
@@ -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 = {
+ 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 = {
+ ACTIVE: 'Aktiv',
+ PAUSED: 'Pausiert',
+ ERROR: 'Fehler',
+ PENDING_REVIEW: 'Prüfung ausstehend',
+ DISABLED: 'Deaktiviert',
+}
+
+export const SOURCE_STATUS_COLORS: Record = {
+ 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 = {
+ APPROVED: 'Genehmigt',
+ NEEDS_LEGAL_REVIEW: 'Rechtliche Prüfung',
+ RESTRICTED: 'Eingeschränkt',
+ BLOCKED: 'Gesperrt',
+ UNKNOWN: 'Unbekannt',
+}
+
+export const TERMS_STATUS_COLORS: Record = {
+ 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 = {
+ RUNNING: 'Läuft',
+ COMPLETED: 'Abgeschlossen',
+ FAILED: 'Fehlgeschlagen',
+ PARTIAL: 'Teilweise',
+ CANCELLED: 'Abgebrochen',
+}
+
+export const CONNECTOR_RUN_STATUS_COLORS: Record = {
+ 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
+}
diff --git a/src/hooks/useDataSources.ts b/src/hooks/useDataSources.ts
new file mode 100644
index 0000000..e3d3420
--- /dev/null
+++ b/src/hooks/useDataSources.ts
@@ -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'] })
+ },
+ })
+}
diff --git a/src/mock-data/dataSources.ts b/src/mock-data/dataSources.ts
new file mode 100644
index 0000000..e2051be
--- /dev/null
+++ b/src/mock-data/dataSources.ts
@@ -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.',
+ },
+]
diff --git a/src/pages/ops/SourceMonitoring.tsx b/src/pages/ops/SourceMonitoring.tsx
new file mode 100644
index 0000000..5a37635
--- /dev/null
+++ b/src/pages/ops/SourceMonitoring.tsx
@@ -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(null)
+ const [filters, setFilters] = useState({})
+
+ const { data: sources = [], isLoading } = useDataSources(filters)
+ const { data: selectedSource = null } = useDataSource(selectedId)
+
+ return (
+
+
+
+ {/* Left: Source list — fixed 400px */}
+
+
+
+ {/* Right: Detail panel */}
+
+
+
+
+
+ )
+}
diff --git a/src/provider/IDataSourceProvider.ts b/src/provider/IDataSourceProvider.ts
new file mode 100644
index 0000000..c9907f3
--- /dev/null
+++ b/src/provider/IDataSourceProvider.ts
@@ -0,0 +1,10 @@
+import type { DataSource, ConnectorRun, SourceStatus, TermsStatus, SourceFilters } from '../domain/dataSource'
+
+export interface IDataSourceProvider {
+ getSources(filters?: SourceFilters): Promise
+ getSource(id: string): Promise
+ getConnectorRuns(sourceId: string): Promise
+ triggerMockRun(sourceId: string): Promise
+ updateSourceStatus(id: string, status: SourceStatus): Promise
+ markTermsStatus(id: string, termsStatus: TermsStatus): Promise
+}
diff --git a/src/provider/MockupDataSourceProvider.ts b/src/provider/MockupDataSourceProvider.ts
new file mode 100644
index 0000000..66c9c83
--- /dev/null
+++ b/src/provider/MockupDataSourceProvider.ts
@@ -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]
+ },
+}
diff --git a/src/services/sourceService.ts b/src/services/sourceService.ts
new file mode 100644
index 0000000..0ae6788
--- /dev/null
+++ b/src/services/sourceService.ts
@@ -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> {
+ const data = await provider.getSources(filters)
+ return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
+ },
+
+ async getSource(id: string): Promise> {
+ const data = await provider.getSource(id)
+ return { data }
+ },
+
+ async getConnectorRuns(sourceId: string): Promise> {
+ const data = await provider.getConnectorRuns(sourceId)
+ return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
+ },
+
+ async triggerMockRun(sourceId: string): Promise> {
+ const data = await provider.triggerMockRun(sourceId)
+ return { data }
+ },
+
+ async updateSourceStatus(id: string, status: SourceStatus): Promise> {
+ const data = await provider.updateSourceStatus(id, status)
+ return { data }
+ },
+
+ async markTermsStatus(id: string, termsStatus: TermsStatus): Promise> {
+ const data = await provider.markTermsStatus(id, termsStatus)
+ return { data }
+ },
+}