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 && ( + + )} + {source.status === SourceStatus.ACTIVE && ( + + )} + {source.status === SourceStatus.PAUSED && ( + + )} + {source.termsStatus !== TermsStatus.NEEDS_LEGAL_REVIEW && ( + + + + )} + + + + + + )} + + {/* 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 } + }, +}