From 8efd6e19748fb1d74b4a3db2f05f65e048a258cf Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Fri, 15 May 2026 15:20:06 +0200 Subject: [PATCH] feat: F021 market intelligence & signal discovery --- src/App.tsx | 2 + src/components/layout/AppShell.tsx | 2 + .../ops/MarketSignalDetailPanel.tsx | 239 ++++++++++++++++++ src/pages/ops/MarketIntelligence.tsx | 46 ++++ 4 files changed, 289 insertions(+) create mode 100644 src/components/ops/MarketSignalDetailPanel.tsx create mode 100644 src/pages/ops/MarketIntelligence.tsx 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 + + + + + + + + + + + )} + + + ) +} 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 ( + + + + + + + + + + + + ) +}