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
+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>
)
}