feat: F021 market intelligence & signal discovery

This commit is contained in:
Benjamin Sutter
2026-05-15 15:20:06 +02:00
parent 7bed2fec86
commit 8efd6e1974
4 changed files with 289 additions and 0 deletions
+2
View File
@@ -21,6 +21,7 @@ const Shortlists = lazy(() => import('./pages/demand/Shortlists'))
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 ActivityTimeline = lazy(() => import('./pages/ops/ActivityTimeline'))
function App() {
@@ -58,6 +59,7 @@ function App() {
<Route path="/ops/review-queue" element={<ReviewQueue />} />
<Route path="/ops/ai-monitoring" element={<AIMonitoring />} />
<Route path="/ops/governance" element={<Governance />} />
<Route path="/ops/market-intelligence" element={<MarketIntelligence />} />
<Route path="/ops/activity-timeline" element={<ActivityTimeline />} />
</Route>
</Route>
+2
View File
@@ -29,6 +29,7 @@ import {
ChevronRight,
Sparkles,
Clock,
Radar,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { OrganizationContextBadge } from './OrganizationContextBadge'
@@ -98,6 +99,7 @@ const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
{ path: '/ops/review-queue', label: 'Review Queue', icon: ClipboardList },
{ 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/activity-timeline', label: 'Aktivitäts-Timeline', icon: Clock },
],
},
@@ -0,0 +1,239 @@
import { Box, Button, Chip, CircularProgress, Divider, Tooltip, Typography } from '@mui/material'
import {
Building2, FileText, Newspaper, TrendingUp, FileCheck2,
HardHat, Database, CalendarClock, Search, PenLine, CheckCircle2,
XCircle, Send, Link2,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type { MarketSignal } from '../../domain/marketSignal'
import {
MARKET_SIGNAL_SOURCE_LABELS,
SIGNAL_PROCESSING_STATUS_LABELS,
SIGNAL_PROCESSING_STATUS_COLORS,
SignalProcessingStatus,
ExtractedEntityType,
} from '../../domain/marketSignal'
import { SensitivityLevel } from '../../domain/enums'
import { FreshnessBadge } from '../badges/FreshnessBadge'
import { SourceReliabilityBadge } from './SourceReliabilityBadge'
import { SignalConfidenceBadge } from './SignalConfidenceBadge'
import { SensitivityWarningPanel } from './SensitivityWarningPanel'
import { SignalEvidenceList } from './SignalEvidenceList'
import { SignalConversionPanel } from './SignalConversionPanel'
import { MarketSignalEmptyState } from './MarketSignalEmptyState'
import { useUpdateSignalStatus, useCreateReviewTask } from '../../hooks/useMarketSignals'
const SOURCE_ICONS: Record<string, LucideIcon> = {
PUBLIC_LISTING_PLATFORM: Building2,
BUILDING_PERMIT_REGISTER: FileText,
COMPANY_NEWS: Newspaper,
JOB_GROWTH_SIGNAL: TrendingUp,
COMMERCIAL_REGISTER: FileCheck2,
INFRASTRUCTURE_PROJECT: HardHat,
PORTFOLIO_IMPORT: Database,
LEASE_EXPIRY_DATA: CalendarClock,
USER_DEMAND_SIGNAL: Search,
MANUAL_ANALYST_SIGNAL: PenLine,
}
const ENTITY_TYPE_LABELS: Record<string, string> = {
[ExtractedEntityType.COMPANY]: 'Unternehmen',
[ExtractedEntityType.PERSON]: 'Person',
[ExtractedEntityType.LOCATION]: 'Ort',
[ExtractedEntityType.ASSET]: 'Objekt',
[ExtractedEntityType.DATE]: 'Datum',
}
const SENSITIVITY_LABELS: Record<string, string> = {
[SensitivityLevel.PUBLIC]: 'Öffentlich',
[SensitivityLevel.INTERNAL]: 'Intern',
[SensitivityLevel.CONFIDENTIAL]: 'Vertraulich',
[SensitivityLevel.RESTRICTED]: 'Eingeschränkt',
}
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',
})
}
interface MarketSignalDetailPanelProps {
signal: MarketSignal | null
}
export function MarketSignalDetailPanel({ signal }: MarketSignalDetailPanelProps) {
const { mutate: updateStatus, isPending: isUpdating } = useUpdateSignalStatus()
const { mutate: sendToReview, isPending: isSending } = useCreateReviewTask()
if (!signal) return <MarketSignalEmptyState variant="not-found" />
const SourceIcon = SOURCE_ICONS[signal.sourceCategory] ?? Building2
const { bg, fg } = SIGNAL_PROCESSING_STATUS_COLORS[signal.processingStatus]
const NON_ACTIONABLE_STATUSES: SignalProcessingStatus[] = [
SignalProcessingStatus.REJECTED,
SignalProcessingStatus.ARCHIVED,
SignalProcessingStatus.CONVERTED_TO_FUTURE_AVAILABILITY,
]
const isActionable = !NON_ACTIONABLE_STATUSES.includes(signal.processingStatus)
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', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 600, lineHeight: 1.3, pr: 2 }}>
{signal.title}
</Typography>
<Chip
size="small"
label={SIGNAL_PROCESSING_STATUS_LABELS[signal.processingStatus]}
sx={{ bgcolor: bg, color: fg, border: 'none', fontWeight: 600, flexShrink: 0 }}
/>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<SourceIcon size={13} color="#64748b" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{MARKET_SIGNAL_SOURCE_LABELS[signal.sourceCategory]} · {signal.location} · {formatDate(signal.detectedAt)}
</Typography>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
<SourceReliabilityBadge score={signal.sourceReliabilityScore} />
<SignalConfidenceBadge score={signal.confidenceScore} />
<FreshnessBadge status={signal.freshnessStatus} />
<Chip
size="small"
label={SENSITIVITY_LABELS[signal.sensitivityLevel]}
sx={{
bgcolor: 'transparent', border: '1px solid',
borderColor: signal.sensitivityLevel === SensitivityLevel.CONFIDENTIAL ? '#ef4444'
: signal.sensitivityLevel === SensitivityLevel.RESTRICTED ? '#7c3aed'
: 'divider',
color: signal.sensitivityLevel === SensitivityLevel.CONFIDENTIAL ? '#dc2626'
: signal.sensitivityLevel === SensitivityLevel.RESTRICTED ? '#7c3aed'
: 'text.secondary',
fontSize: '0.7rem',
}}
/>
</Box>
</Box>
{/* Body */}
<Box sx={{ p: 3, flex: 1 }}>
<SensitivityWarningPanel sensitivityLevel={signal.sensitivityLevel} />
{/* Summary */}
<Typography variant="body2" sx={{ color: '#1e293b', lineHeight: 1.6, mb: 2.5 }}>
{signal.summary}
</Typography>
<Divider sx={{ mb: 2 }} />
{/* Evidence */}
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5 }}>
Quellenangaben & Evidenz
</Typography>
<SignalEvidenceList evidence={signal.evidence} />
{signal.extractedEntities.length > 0 && (
<>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Extrahierte Entitäten
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
{signal.extractedEntities.map((entity, i) => (
<Tooltip
key={i}
title={`${ENTITY_TYPE_LABELS[entity.type]} · Konfidenz ${Math.round(entity.confidence * 100)}%`}
>
<Chip
size="small"
label={entity.value}
sx={{
bgcolor: 'rgba(30,58,95,0.08)',
color: '#1e3a5f',
border: 'none',
fontSize: '0.75rem',
cursor: 'default',
}}
/>
</Tooltip>
))}
</Box>
</>
)}
{signal.analystNotes && (
<>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.75 }}>
Analyst-Notizen
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', fontStyle: 'italic', lineHeight: 1.5 }}>
{signal.analystNotes}
</Typography>
</>
)}
{/* Conversion panel */}
<Divider sx={{ my: 2 }} />
<SignalConversionPanel signal={signal} />
{/* Actions */}
{isActionable && (
<>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5 }}>
Aktionen
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
<Button
size="small"
variant="outlined"
startIcon={isUpdating ? <CircularProgress size={12} /> : <CheckCircle2 size={14} />}
disabled={isUpdating || signal.processingStatus === SignalProcessingStatus.APPROVED_AS_SIGNAL}
onClick={() => updateStatus({ id: signal.id, status: SignalProcessingStatus.APPROVED_AS_SIGNAL })}
sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: '#16a34a', color: '#16a34a', '&:hover': { borderColor: '#15803d', bgcolor: 'rgba(22,163,74,0.04)' } }}
>
Als relevant markieren
</Button>
<Button
size="small"
variant="outlined"
startIcon={<Send size={14} />}
disabled={isSending}
onClick={() => sendToReview(signal.id)}
sx={{ textTransform: 'none', fontSize: '0.8rem' }}
>
Zur Prüfung senden
</Button>
<Tooltip title="Objekt verknüpfen (Demo)">
<Button
size="small"
variant="outlined"
startIcon={<Link2 size={14} />}
sx={{ textTransform: 'none', fontSize: '0.8rem', color: 'text.secondary', borderColor: 'divider' }}
>
Verknüpfen
</Button>
</Tooltip>
<Button
size="small"
variant="outlined"
startIcon={<XCircle size={14} />}
disabled={isUpdating}
onClick={() => updateStatus({ id: signal.id, status: SignalProcessingStatus.REJECTED })}
sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: '#ef4444', color: '#dc2626', '&:hover': { borderColor: '#dc2626', bgcolor: 'rgba(239,68,68,0.04)' } }}
>
Ablehnen
</Button>
</Box>
</>
)}
</Box>
</Box>
)
}
+46
View File
@@ -0,0 +1,46 @@
import { useState } from 'react'
import { Box } from '@mui/material'
import { PageHeader } from '../../components/layout'
import { SignalInbox, MarketSignalDetailPanel } from '../../components/ops'
import { useMarketSignals } from '../../hooks/useMarketSignals'
import type { MarketSignalFilters } from '../../domain/marketSignal'
export default function MarketIntelligence() {
const [selectedId, setSelectedId] = useState<string | null>(null)
const [filters, setFilters] = useState<MarketSignalFilters>({})
const { data: signals = [], isLoading } = useMarketSignals(filters)
const selectedSignal = signals.find((s) => s.id === selectedId) ?? null
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<PageHeader
title="Market Intelligence"
subtitle="Signale, Marktdaten und Frühwarnindikatoren"
/>
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
<Box
sx={{
width: 380,
flexShrink: 0,
borderRight: '1px solid #e2e8f0',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<SignalInbox
signals={signals}
isLoading={isLoading}
selectedId={selectedId}
onSelect={setSelectedId}
filters={filters}
onFiltersChange={setFilters}
/>
</Box>
<Box sx={{ flex: 1, overflow: 'hidden' }}>
<MarketSignalDetailPanel signal={selectedSignal} />
</Box>
</Box>
</Box>
)
}