diff --git a/src/App.tsx b/src/App.tsx
index 03a137e..72fd579 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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() {
} />
} />
} />
+ } />
} />
diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx
index fe5f741..ac851a7 100644
--- a/src/components/layout/AppShell.tsx
+++ b/src/components/layout/AppShell.tsx
@@ -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 = {
{ 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 },
],
},
diff --git a/src/components/ops/MarketSignalDetailPanel.tsx b/src/components/ops/MarketSignalDetailPanel.tsx
new file mode 100644
index 0000000..4fc1f40
--- /dev/null
+++ b/src/components/ops/MarketSignalDetailPanel.tsx
@@ -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 = {
+ 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 = {
+ [ExtractedEntityType.COMPANY]: 'Unternehmen',
+ [ExtractedEntityType.PERSON]: 'Person',
+ [ExtractedEntityType.LOCATION]: 'Ort',
+ [ExtractedEntityType.ASSET]: 'Objekt',
+ [ExtractedEntityType.DATE]: 'Datum',
+}
+
+const SENSITIVITY_LABELS: Record = {
+ [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
+
+ 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 (
+
+ {/* Header */}
+
+
+
+ {signal.title}
+
+
+
+
+
+
+
+ {MARKET_SIGNAL_SOURCE_LABELS[signal.sourceCategory]} · {signal.location} · {formatDate(signal.detectedAt)}
+
+
+
+
+
+
+
+
+
+
+
+ {/* Body */}
+
+
+
+ {/* Summary */}
+
+ {signal.summary}
+
+
+
+
+ {/* Evidence */}
+
+ Quellenangaben & Evidenz
+
+
+
+ {signal.extractedEntities.length > 0 && (
+ <>
+
+
+ Extrahierte Entitäten
+
+
+ {signal.extractedEntities.map((entity, i) => (
+
+
+
+ ))}
+
+ >
+ )}
+
+ {signal.analystNotes && (
+ <>
+
+
+ Analyst-Notizen
+
+
+ {signal.analystNotes}
+
+ >
+ )}
+
+ {/* Conversion panel */}
+
+
+
+ {/* Actions */}
+ {isActionable && (
+ <>
+
+
+ Aktionen
+
+
+ : }
+ 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
+
+ }
+ disabled={isSending}
+ onClick={() => sendToReview(signal.id)}
+ sx={{ textTransform: 'none', fontSize: '0.8rem' }}
+ >
+ Zur Prüfung senden
+
+
+ }
+ sx={{ textTransform: 'none', fontSize: '0.8rem', color: 'text.secondary', borderColor: 'divider' }}
+ >
+ Verknüpfen
+
+
+ }
+ 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
+
+
+ >
+ )}
+
+
+ )
+}
diff --git a/src/pages/ops/MarketIntelligence.tsx b/src/pages/ops/MarketIntelligence.tsx
new file mode 100644
index 0000000..825abf1
--- /dev/null
+++ b/src/pages/ops/MarketIntelligence.tsx
@@ -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(null)
+ const [filters, setFilters] = useState({})
+ const { data: signals = [], isLoading } = useMarketSignals(filters)
+ const selectedSignal = signals.find((s) => s.id === selectedId) ?? null
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}