From c65e565dc7fd52b2b3621786afb86bdec4bae6c5 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Fri, 15 May 2026 15:52:53 +0200 Subject: [PATCH] feat: F023 signal-to-match pipeline & shadow market decision flow --- src/App.tsx | 2 + src/components/layout/AppShell.tsx | 2 + src/components/ops/ConfidenceGatePanel.tsx | 131 +++ src/components/ops/EvidenceGatePanel.tsx | 65 ++ src/components/ops/FeedEligibilityBadge.tsx | 58 ++ .../ops/MarketSignalDetailPanel.tsx | 2 + src/components/ops/MatchabilityGatePanel.tsx | 70 ++ src/components/ops/ReviewGatePanel.tsx | 83 ++ src/components/ops/SensitivityGatePanel.tsx | 86 ++ src/components/ops/SignalPipelineStepper.tsx | 36 + src/components/ops/SignalPipelineView.tsx | 152 ++++ .../ops/SignalToMatchAuditTrail.tsx | 90 ++ src/components/ops/index.ts | 9 + src/domain/signalPipeline.ts | 125 +++ src/hooks/useSignalPipeline.ts | 48 + src/mock-data/signalPipelines.ts | 860 ++++++++++++++++++ src/pages/ops/SignalPipeline.tsx | 49 + src/provider/ISignalPipelineProvider.ts | 8 + src/provider/MockupSignalPipelineProvider.ts | 57 ++ src/services/signalPipelineService.ts | 24 + 20 files changed, 1957 insertions(+) create mode 100644 src/components/ops/ConfidenceGatePanel.tsx create mode 100644 src/components/ops/EvidenceGatePanel.tsx create mode 100644 src/components/ops/FeedEligibilityBadge.tsx create mode 100644 src/components/ops/MatchabilityGatePanel.tsx create mode 100644 src/components/ops/ReviewGatePanel.tsx create mode 100644 src/components/ops/SensitivityGatePanel.tsx create mode 100644 src/components/ops/SignalPipelineStepper.tsx create mode 100644 src/components/ops/SignalPipelineView.tsx create mode 100644 src/components/ops/SignalToMatchAuditTrail.tsx create mode 100644 src/domain/signalPipeline.ts create mode 100644 src/hooks/useSignalPipeline.ts create mode 100644 src/mock-data/signalPipelines.ts create mode 100644 src/pages/ops/SignalPipeline.tsx create mode 100644 src/provider/ISignalPipelineProvider.ts create mode 100644 src/provider/MockupSignalPipelineProvider.ts create mode 100644 src/services/signalPipelineService.ts diff --git a/src/App.tsx b/src/App.tsx index 4932b45..0b127a1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,6 +24,7 @@ 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')) +const SignalPipeline = lazy(() => import('./pages/ops/SignalPipeline')) function App() { return ( @@ -63,6 +64,7 @@ function App() { } /> } /> } /> + } /> diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 3dcf5f8..3c08bbf 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -31,6 +31,7 @@ import { Clock, Radar, ServerCog, + GitBranch, } from 'lucide-react' import type { LucideIcon } from 'lucide-react' import { OrganizationContextBadge } from './OrganizationContextBadge' @@ -102,6 +103,7 @@ const WORKSPACE_CONFIG: Record = { { 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/signal-pipeline', label: 'Signal Pipeline', icon: GitBranch }, { path: '/ops/activity-timeline', label: 'Aktivitäts-Timeline', icon: Clock }, ], }, diff --git a/src/components/ops/ConfidenceGatePanel.tsx b/src/components/ops/ConfidenceGatePanel.tsx new file mode 100644 index 0000000..8d1b32b --- /dev/null +++ b/src/components/ops/ConfidenceGatePanel.tsx @@ -0,0 +1,131 @@ +import { Box, Chip, Divider, Paper, Typography } from '@mui/material' +import { CheckCircle2, ChevronRight, Target, XCircle } from 'lucide-react' +import type { GateEvaluation } from '../../domain/signalPipeline' +import { GateStatus, GATE_STATUS_LABELS, GATE_STATUS_COLORS } from '../../domain/signalPipeline' + +interface ConfidenceGatePanelProps { + gate: GateEvaluation +} + +function ConfidenceBar({ value }: { value: string | undefined }) { + if (!value) return null + + const score = parseFloat(value) + if (isNaN(score)) return null + + const pct = Math.round(score * 100) + + const barColor = + score >= 0.75 ? '#15803d' + : score >= 0.35 ? '#d97706' + : '#dc2626' + + const threshold = + score >= 0.75 ? 'Hoch (≥ 75%)' + : score >= 0.35 ? 'Mittel (≥ 35%)' + : 'Niedrig (< 35%)' + + return ( + + + + Konfidenz-Schwelle: {threshold} + + + {pct}% + + + + + + + + 0% + + + 35% + + + 75% + + + 100% + + + + ) +} + +export function ConfidenceGatePanel({ gate }: ConfidenceGatePanelProps) { + const { bg, fg } = GATE_STATUS_COLORS[gate.status] + + const confidenceCheck = gate.checks.find(c => c.label.startsWith('Konfidenz')) + const confidenceValue = confidenceCheck?.value + + return ( + + + + + Konfidenz-Gate + + + + + + + + {gate.checks.map((check, i) => ( + + {check.passed + ? + : + } + + {check.label} + {check.value && ( + + {check.value} + + )} + {check.note && ( + + ({check.note}) + + )} + + + ))} + + {gate.reason && ( + + {gate.reason} + + )} + {gate.nextAction && gate.status !== GateStatus.PASSED && ( + + + + {gate.nextAction} + + + )} + + + ) +} diff --git a/src/components/ops/EvidenceGatePanel.tsx b/src/components/ops/EvidenceGatePanel.tsx new file mode 100644 index 0000000..637a0d1 --- /dev/null +++ b/src/components/ops/EvidenceGatePanel.tsx @@ -0,0 +1,65 @@ +import { Box, Chip, Divider, Paper, Typography } from '@mui/material' +import { CheckCircle2, ChevronRight, FileSearch, XCircle } from 'lucide-react' +import type { GateEvaluation } from '../../domain/signalPipeline' +import { GateStatus, GATE_STATUS_LABELS, GATE_STATUS_COLORS } from '../../domain/signalPipeline' + +interface EvidenceGatePanelProps { + gate: GateEvaluation +} + +export function EvidenceGatePanel({ gate }: EvidenceGatePanelProps) { + const { bg, fg } = GATE_STATUS_COLORS[gate.status] + + return ( + + + + + Evidenz-Gate + + + + + + + + {gate.checks.map((check, i) => ( + + {check.passed + ? + : + } + + {check.label} + {check.value && ( + + {check.value} + + )} + + + ))} + {gate.reason && ( + + {gate.reason} + + )} + {gate.nextAction && gate.status !== GateStatus.PASSED && ( + + + + {gate.nextAction} + + + )} + + + ) +} diff --git a/src/components/ops/FeedEligibilityBadge.tsx b/src/components/ops/FeedEligibilityBadge.tsx new file mode 100644 index 0000000..cab496d --- /dev/null +++ b/src/components/ops/FeedEligibilityBadge.tsx @@ -0,0 +1,58 @@ +import { Chip } from '@mui/material' +import { CheckCircle2, MinusCircle } from 'lucide-react' +import type { MarketSignal } from '../../domain/marketSignal' +import { SignalProcessingStatus } from '../../domain/marketSignal' +import { SensitivityLevel } from '../../domain/enums' + +interface FeedEligibilityBadgeProps { + signal: MarketSignal +} + +export function FeedEligibilityBadge({ signal }: FeedEligibilityBadgeProps) { + const ELIGIBLE_STATUSES: SignalProcessingStatus[] = [ + SignalProcessingStatus.APPROVED_AS_SIGNAL, + SignalProcessingStatus.CONVERTED_TO_FUTURE_AVAILABILITY, + ] + const BLOCKING_SENSITIVITY: SensitivityLevel[] = [ + SensitivityLevel.CONFIDENTIAL, + SensitivityLevel.RESTRICTED, + ] + + const isEligible = + ELIGIBLE_STATUSES.includes(signal.processingStatus) && + !BLOCKING_SENSITIVITY.includes(signal.sensitivityLevel) + + if (isEligible) { + return ( + } + sx={{ + bgcolor: 'rgba(22,163,74,0.1)', + color: '#15803d', + border: 'none', + fontSize: '0.7rem', + fontWeight: 600, + '& .MuiChip-icon': { ml: 0.75 }, + }} + /> + ) + } + + return ( + } + sx={{ + bgcolor: 'rgba(148,163,184,0.1)', + color: '#64748b', + border: 'none', + fontSize: '0.7rem', + fontWeight: 600, + '& .MuiChip-icon': { ml: 0.75 }, + }} + /> + ) +} diff --git a/src/components/ops/MarketSignalDetailPanel.tsx b/src/components/ops/MarketSignalDetailPanel.tsx index 4fc1f40..4b1e21b 100644 --- a/src/components/ops/MarketSignalDetailPanel.tsx +++ b/src/components/ops/MarketSignalDetailPanel.tsx @@ -21,6 +21,7 @@ import { SensitivityWarningPanel } from './SensitivityWarningPanel' import { SignalEvidenceList } from './SignalEvidenceList' import { SignalConversionPanel } from './SignalConversionPanel' import { MarketSignalEmptyState } from './MarketSignalEmptyState' +import { FeedEligibilityBadge } from './FeedEligibilityBadge' import { useUpdateSignalStatus, useCreateReviewTask } from '../../hooks/useMarketSignals' const SOURCE_ICONS: Record = { @@ -117,6 +118,7 @@ export function MarketSignalDetailPanel({ signal }: MarketSignalDetailPanelProps fontSize: '0.7rem', }} /> + diff --git a/src/components/ops/MatchabilityGatePanel.tsx b/src/components/ops/MatchabilityGatePanel.tsx new file mode 100644 index 0000000..355670e --- /dev/null +++ b/src/components/ops/MatchabilityGatePanel.tsx @@ -0,0 +1,70 @@ +import { Box, Chip, Divider, Paper, Typography } from '@mui/material' +import { CheckCircle2, ChevronRight, Layers, XCircle } from 'lucide-react' +import type { GateEvaluation } from '../../domain/signalPipeline' +import { GateStatus, GATE_STATUS_LABELS, GATE_STATUS_COLORS } from '../../domain/signalPipeline' + +interface MatchabilityGatePanelProps { + gate: GateEvaluation +} + +export function MatchabilityGatePanel({ gate }: MatchabilityGatePanelProps) { + const { bg, fg } = GATE_STATUS_COLORS[gate.status] + + return ( + + + + + Matchbarkeits-Gate + + + + + + + + {gate.checks.map((check, i) => ( + + {check.passed + ? + : + } + + {check.label} + {check.value && ( + + {check.value} + + )} + {check.note && ( + + – {check.note} + + )} + + + ))} + {gate.reason && ( + + {gate.reason} + + )} + {gate.nextAction && gate.status !== GateStatus.PASSED && ( + + + + {gate.nextAction} + + + )} + + + ) +} diff --git a/src/components/ops/ReviewGatePanel.tsx b/src/components/ops/ReviewGatePanel.tsx new file mode 100644 index 0000000..b70ae71 --- /dev/null +++ b/src/components/ops/ReviewGatePanel.tsx @@ -0,0 +1,83 @@ +import { Box, Chip, Divider, Paper, Typography } from '@mui/material' +import { CheckCircle2, ChevronRight, ClipboardCheck, XCircle } from 'lucide-react' +import type { GateEvaluation } from '../../domain/signalPipeline' +import { GateStatus, GATE_STATUS_LABELS, GATE_STATUS_COLORS } from '../../domain/signalPipeline' + +interface ReviewGatePanelProps { + gate: GateEvaluation +} + +export function ReviewGatePanel({ gate }: ReviewGatePanelProps) { + const { bg, fg } = GATE_STATUS_COLORS[gate.status] + + const reviewerNotes = gate.checks.filter( + c => c.value && c.label.toLowerCase().includes('review') + ) + + return ( + + + + + Review-Gate + + + + + + + + {gate.checks.map((check, i) => ( + + {check.passed + ? + : + } + + {check.label} + {check.value && ( + + {check.value} + + )} + + + ))} + + {reviewerNotes.length > 0 && ( + + + Reviewer-Informationen + + {reviewerNotes.map((note, i) => ( + + {note.label}: {note.value} + + ))} + + )} + + {gate.reason && ( + + {gate.reason} + + )} + {gate.nextAction && gate.status !== GateStatus.PASSED && ( + + + + {gate.nextAction} + + + )} + + + ) +} diff --git a/src/components/ops/SensitivityGatePanel.tsx b/src/components/ops/SensitivityGatePanel.tsx new file mode 100644 index 0000000..918f890 --- /dev/null +++ b/src/components/ops/SensitivityGatePanel.tsx @@ -0,0 +1,86 @@ +import { Alert, Box, Chip, Divider, Paper, Typography } from '@mui/material' +import { CheckCircle2, ChevronRight, ShieldCheck, XCircle } from 'lucide-react' +import type { GateEvaluation } from '../../domain/signalPipeline' +import { GateStatus, GATE_STATUS_LABELS, GATE_STATUS_COLORS } from '../../domain/signalPipeline' + +interface SensitivityGatePanelProps { + gate: GateEvaluation +} + +export function SensitivityGatePanel({ gate }: SensitivityGatePanelProps) { + const { bg, fg } = GATE_STATUS_COLORS[gate.status] + + const confidentialCheck = gate.checks.find(c => c.label === 'Nicht CONFIDENTIAL' && !c.passed) + const restrictedCheck = gate.checks.find(c => c.label === 'Nicht RESTRICTED' && !c.passed) + + return ( + + + + + Sensitivitäts-Gate + + + + + + + + {gate.checks.map((check, i) => ( + + {check.passed + ? + : + } + + {check.label} + {check.value && ( + + {check.value} + + )} + + + ))} + + {gate.status === GateStatus.FAILED && ( + + {confidentialCheck && ( + + Sensitivitätsstufe CONFIDENTIAL: Dieses Signal darf nicht im Demand Feed erscheinen. + Mieter- oder Vertragsidentitäten sind schützenswert und nur intern zugänglich. + + )} + {restrictedCheck && ( + + Sensitivitätsstufe RESTRICTED: Zugriff auf dieses Signal ist rollenbasiert eingeschränkt. + Nur autorisierte Nutzer mit entsprechender Berechtigung dürfen es einsehen. + + )} + + )} + + {gate.reason && ( + + {gate.reason} + + )} + {gate.nextAction && gate.status !== GateStatus.PASSED && ( + + + + {gate.nextAction} + + + )} + + + ) +} diff --git a/src/components/ops/SignalPipelineStepper.tsx b/src/components/ops/SignalPipelineStepper.tsx new file mode 100644 index 0000000..bfb400e --- /dev/null +++ b/src/components/ops/SignalPipelineStepper.tsx @@ -0,0 +1,36 @@ +import { Stepper, Step, StepLabel } from '@mui/material' +import type { PipelineState } from '../../domain/signalPipeline' +import { PIPELINE_STAGE_ORDER, PIPELINE_STAGE_LABELS } from '../../domain/signalPipeline' + +interface SignalPipelineStepperProps { + pipelineState: PipelineState +} + +export function SignalPipelineStepper({ pipelineState }: SignalPipelineStepperProps) { + const currentStageIndex = PIPELINE_STAGE_ORDER.indexOf(pipelineState.currentStage) + + return ( + + {PIPELINE_STAGE_ORDER.map((stage) => ( + + {PIPELINE_STAGE_LABELS[stage]} + + ))} + + ) +} diff --git a/src/components/ops/SignalPipelineView.tsx b/src/components/ops/SignalPipelineView.tsx new file mode 100644 index 0000000..6906514 --- /dev/null +++ b/src/components/ops/SignalPipelineView.tsx @@ -0,0 +1,152 @@ +import { Alert, Box, Button, CircularProgress, Divider, Typography } from '@mui/material' +import { Rocket } from 'lucide-react' +import type { MarketSignal } from '../../domain/marketSignal' +import { GateStatus } from '../../domain/signalPipeline' +import { + useSignalPipelineState, + useSignalAuditTrail, + usePublishToFutureAvailability, +} from '../../hooks/useSignalPipeline' +import { FeedEligibilityBadge } from './FeedEligibilityBadge' +import { SignalPipelineStepper } from './SignalPipelineStepper' +import { EvidenceGatePanel } from './EvidenceGatePanel' +import { ConfidenceGatePanel } from './ConfidenceGatePanel' +import { SensitivityGatePanel } from './SensitivityGatePanel' +import { ReviewGatePanel } from './ReviewGatePanel' +import { MatchabilityGatePanel } from './MatchabilityGatePanel' +import { SignalToMatchAuditTrail } from './SignalToMatchAuditTrail' + +interface SignalPipelineViewProps { + signal: MarketSignal +} + +export function SignalPipelineView({ signal }: SignalPipelineViewProps) { + const { data: pipelineState, isLoading: isLoadingPipeline } = useSignalPipelineState(signal.id) + const { data: auditTrail = [], isLoading: isLoadingAudit } = useSignalAuditTrail(signal.id) + const { mutate: publish, isPending: isPublishing } = usePublishToFutureAvailability() + + const canPublish = + pipelineState !== null && + pipelineState !== undefined && + pipelineState.gates.REVIEW_GATE.status === GateStatus.PASSED && + !pipelineState.publishedToFutureAvailability + + return ( + + {/* Header */} + + + {signal.title} + + + + {pipelineState?.feedDisclaimer && ( + + {pipelineState.feedDisclaimer} + + )} + + + + {/* Pipeline Stepper */} + + {isLoadingPipeline && ( + + + + )} + {pipelineState && } + + + {/* Gate panels */} + + {isLoadingPipeline && !pipelineState && ( + + + + )} + + {pipelineState && ( + <> + + Gate-Bewertungen + + + + + + + + + {/* Feed eligibility summary */} + {pipelineState.overallEligible && pipelineState.publishedToFutureAvailability && ( + + Signal ist im Future Availability Feed publiziert + {pipelineState.publishedAt && ( + <> · {new Date(pipelineState.publishedAt).toLocaleString('de-CH', { + day: '2-digit', month: '2-digit', year: '2-digit', + hour: '2-digit', minute: '2-digit', + })} + )} + + )} + + {pipelineState.overallEligible && !pipelineState.publishedToFutureAvailability && ( + + Signal ist feed-fähig – noch nicht publiziert. + + )} + + {/* Publish button */} + {canPublish && ( + + + + )} + + + + + Audit Trail + + {isLoadingAudit ? ( + + + + ) : ( + + )} + + )} + + + ) +} diff --git a/src/components/ops/SignalToMatchAuditTrail.tsx b/src/components/ops/SignalToMatchAuditTrail.tsx new file mode 100644 index 0000000..3a19dd1 --- /dev/null +++ b/src/components/ops/SignalToMatchAuditTrail.tsx @@ -0,0 +1,90 @@ +import { Box, Typography } from '@mui/material' +import type { AuditTrailEntry } from '../../domain/signalPipeline' +import { PIPELINE_STAGE_ORDER } from '../../domain/signalPipeline' + +interface SignalToMatchAuditTrailProps { + entries: AuditTrailEntry[] +} + +function getStageColor(entry: AuditTrailEntry): string { + const idx = PIPELINE_STAGE_ORDER.indexOf(entry.stage) + if (idx <= 0) return '#94a3b8' + if (idx >= 5) return '#15803d' + if (idx >= 3) return '#1e3a5f' + if (idx >= 2) return '#4f46e5' + return '#2563eb' +} + +function formatTimestamp(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', + }) +} + +export function SignalToMatchAuditTrail({ entries }: SignalToMatchAuditTrailProps) { + if (entries.length === 0) { + return ( + + Keine Audit-Einträge vorhanden. + + ) + } + + return ( + + {entries.map((entry, idx) => { + const isLast = idx === entries.length - 1 + const dotColor = getStageColor(entry) + + return ( + + {/* Timeline column */} + + + {!isLast && ( + + )} + + + {/* Content column */} + + + {entry.action} + + + {entry.performedBy} · {formatTimestamp(entry.timestamp)} + + + {entry.details} + + + + ) + })} + + ) +} diff --git a/src/components/ops/index.ts b/src/components/ops/index.ts index 19335f4..b975e22 100644 --- a/src/components/ops/index.ts +++ b/src/components/ops/index.ts @@ -19,3 +19,12 @@ export { SourceList } from './SourceList' export { ConnectorRunTable } from './ConnectorRunTable' export { ConnectorRunDetailDrawer } from './ConnectorRunDetailDrawer' export { SourceDetailPanel } from './SourceDetailPanel' +export { FeedEligibilityBadge } from './FeedEligibilityBadge' +export { SignalPipelineStepper } from './SignalPipelineStepper' +export { EvidenceGatePanel } from './EvidenceGatePanel' +export { ConfidenceGatePanel } from './ConfidenceGatePanel' +export { SensitivityGatePanel } from './SensitivityGatePanel' +export { ReviewGatePanel } from './ReviewGatePanel' +export { MatchabilityGatePanel } from './MatchabilityGatePanel' +export { SignalToMatchAuditTrail } from './SignalToMatchAuditTrail' +export { SignalPipelineView } from './SignalPipelineView' diff --git a/src/domain/signalPipeline.ts b/src/domain/signalPipeline.ts new file mode 100644 index 0000000..267b996 --- /dev/null +++ b/src/domain/signalPipeline.ts @@ -0,0 +1,125 @@ +// ── Pipeline Stages ─────────────────────────────────────────────────────────── +export const PipelineStage = { + STAGE_1_RAW_EVIDENCE: 'STAGE_1_RAW_EVIDENCE', + STAGE_2_NORMALIZED: 'STAGE_2_NORMALIZED', + STAGE_3_ENRICHED: 'STAGE_3_ENRICHED', + STAGE_4_REVIEW_CANDIDATE: 'STAGE_4_REVIEW_CANDIDATE', + STAGE_5_APPROVED_FUTURE: 'STAGE_5_APPROVED_FUTURE', + STAGE_6_MATCHABLE_RESULT: 'STAGE_6_MATCHABLE_RESULT', + STAGE_7_STRATEGIC_INPUT: 'STAGE_7_STRATEGIC_INPUT', +} as const +export type PipelineStage = typeof PipelineStage[keyof typeof PipelineStage] + +export const PIPELINE_STAGE_LABELS: Record = { + STAGE_1_RAW_EVIDENCE: 'Rohe Markt-Evidenz', + STAGE_2_NORMALIZED: 'Normalisiertes Signal', + STAGE_3_ENRICHED: 'Angereichertes Signal', + STAGE_4_REVIEW_CANDIDATE: 'Review-Kandidat', + STAGE_5_APPROVED_FUTURE: 'Genehmigtes Future Signal', + STAGE_6_MATCHABLE_RESULT: 'Matchbares Ergebnis', + STAGE_7_STRATEGIC_INPUT: 'Strategischer Entscheidungs-Input', +} + +export const PIPELINE_STAGE_DESCRIPTIONS: Record = { + STAGE_1_RAW_EVIDENCE: 'Rohe Evidenz aus Quellen gesammelt – noch unverarbeitet', + STAGE_2_NORMALIZED: 'Daten normalisiert, Felder validiert und vereinheitlicht', + STAGE_3_ENRICHED: 'Entitäten extrahiert, Kontext angereichert und bewertet', + STAGE_4_REVIEW_CANDIDATE: 'Signal bereit für manuellen Analyst-Review', + STAGE_5_APPROVED_FUTURE: 'Genehmigt als Future Availability Signal – intern sichtbar', + STAGE_6_MATCHABLE_RESULT: 'Im Unified Result Feed – Match-Engine nutzbar', + STAGE_7_STRATEGIC_INPUT: 'Eingang in Decision Briefs und strategische Analyse', +} + +export const PIPELINE_STAGE_ORDER: PipelineStage[] = [ + 'STAGE_1_RAW_EVIDENCE', + 'STAGE_2_NORMALIZED', + 'STAGE_3_ENRICHED', + 'STAGE_4_REVIEW_CANDIDATE', + 'STAGE_5_APPROVED_FUTURE', + 'STAGE_6_MATCHABLE_RESULT', + 'STAGE_7_STRATEGIC_INPUT', +] + +// ── Gate Types ──────────────────────────────────────────────────────────────── +export const GateType = { + EVIDENCE_GATE: 'EVIDENCE_GATE', + CONFIDENCE_GATE: 'CONFIDENCE_GATE', + SENSITIVITY_GATE: 'SENSITIVITY_GATE', + REVIEW_GATE: 'REVIEW_GATE', + MATCHABILITY_GATE: 'MATCHABILITY_GATE', + FEED_ELIGIBILITY_GATE: 'FEED_ELIGIBILITY_GATE', +} as const +export type GateType = typeof GateType[keyof typeof GateType] + +export const GATE_LABELS: Record = { + EVIDENCE_GATE: 'Evidenz-Gate', + CONFIDENCE_GATE: 'Konfidenz-Gate', + SENSITIVITY_GATE: 'Sensitivitäts-Gate', + REVIEW_GATE: 'Review-Gate', + MATCHABILITY_GATE: 'Matchbarkeits-Gate', + FEED_ELIGIBILITY_GATE: 'Feed-Eignung', +} + +// ── Gate Status ─────────────────────────────────────────────────────────────── +export const GateStatus = { + PASSED: 'PASSED', + FAILED: 'FAILED', + PENDING: 'PENDING', + BLOCKED: 'BLOCKED', + SKIPPED: 'SKIPPED', +} as const +export type GateStatus = typeof GateStatus[keyof typeof GateStatus] + +export const GATE_STATUS_LABELS: Record = { + PASSED: 'Bestanden', + FAILED: 'Fehlgeschlagen', + PENDING: 'Ausstehend', + BLOCKED: 'Blockiert', + SKIPPED: 'Übersprungen', +} + +export const GATE_STATUS_COLORS: Record = { + PASSED: { bg: 'rgba(22,163,74,0.1)', fg: '#15803d' }, + FAILED: { bg: 'rgba(239,68,68,0.1)', fg: '#dc2626' }, + PENDING: { bg: 'rgba(234,179,8,0.1)', fg: '#a16207' }, + BLOCKED: { bg: 'rgba(239,68,68,0.08)', fg: '#dc2626' }, + SKIPPED: { bg: 'rgba(148,163,184,0.1)', fg: '#64748b' }, +} + +// ── Interfaces ──────────────────────────────────────────────────────────────── +export interface GateCheck { + label: string + passed: boolean + value?: string + note?: string +} + +export interface GateEvaluation { + gateType: GateType + status: GateStatus + reason: string + nextAction?: string + evaluatedAt: string + checks: GateCheck[] +} + +export interface PipelineState { + signalId: string + currentStage: PipelineStage + gates: Record + overallEligible: boolean + publishedToFutureAvailability: boolean + publishedAt?: string + feedDisclaimer?: string +} + +export interface AuditTrailEntry { + id: string + signalId: string + timestamp: string + stage: PipelineStage + action: string + performedBy: string + details: string + gateType?: GateType +} diff --git a/src/hooks/useSignalPipeline.ts b/src/hooks/useSignalPipeline.ts new file mode 100644 index 0000000..51ce976 --- /dev/null +++ b/src/hooks/useSignalPipeline.ts @@ -0,0 +1,48 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { signalPipelineService } from '../services/signalPipelineService' +import type { GateType } from '../domain/signalPipeline' + +const STALE_PIPELINE = 15_000 + +export function useSignalPipelineState(signalId: string | null) { + return useQuery({ + queryKey: ['signal-pipeline', signalId], + queryFn: () => signalPipelineService.getPipelineState(signalId!), + enabled: signalId !== null, + staleTime: STALE_PIPELINE, + select: (res) => res.data ?? null, + }) +} + +export function useSignalAuditTrail(signalId: string | null) { + return useQuery({ + queryKey: ['signal-audit-trail', signalId], + queryFn: () => signalPipelineService.getAuditTrail(signalId!), + enabled: signalId !== null, + staleTime: STALE_PIPELINE, + select: (res) => res.data ?? [], + }) +} + +export function useEvaluateGate() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ signalId, gateType }: { signalId: string; gateType: GateType }) => + signalPipelineService.evaluateGate(signalId, gateType), + onSuccess: (_data, { signalId }) => { + queryClient.invalidateQueries({ queryKey: ['signal-pipeline', signalId] }) + }, + }) +} + +export function usePublishToFutureAvailability() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (signalId: string) => signalPipelineService.publishToFutureAvailability(signalId), + onSuccess: (_data, signalId) => { + queryClient.invalidateQueries({ queryKey: ['signal-pipeline', signalId] }) + queryClient.invalidateQueries({ queryKey: ['signal-audit-trail', signalId] }) + queryClient.invalidateQueries({ queryKey: ['market-signals'] }) + }, + }) +} diff --git a/src/mock-data/signalPipelines.ts b/src/mock-data/signalPipelines.ts new file mode 100644 index 0000000..934ec30 --- /dev/null +++ b/src/mock-data/signalPipelines.ts @@ -0,0 +1,860 @@ +import type { PipelineState, GateEvaluation, AuditTrailEntry } from '../domain/signalPipeline' +import { PipelineStage, GateType, GateStatus } from '../domain/signalPipeline' + +function gate( + gateType: GateType, + status: GateStatus, + reason: string, + checks: GateEvaluation['checks'], + nextAction?: string, +): GateEvaluation { + return { gateType, status, reason, checks, nextAction, evaluatedAt: '2026-05-15T06:00:00Z' } +} + +// ── sig-001: STAGE_4_REVIEW_CANDIDATE ───────────────────────────────────────── +const sig001Pipeline: PipelineState = { + signalId: 'sig-001', + currentStage: PipelineStage.STAGE_4_REVIEW_CANDIDATE, + overallEligible: false, + publishedToFutureAvailability: false, + gates: { + EVIDENCE_GATE: gate( + GateType.EVIDENCE_GATE, + GateStatus.PASSED, + 'Mindestens ein valides Evidenzstück vorhanden.', + [ + { label: 'Evidenz vorhanden', passed: true, value: '1 Stück' }, + { label: 'Quelle angegeben', passed: true, value: 'Neue Zürcher Zeitung' }, + { label: 'Inhalt nicht leer', passed: true }, + ], + ), + CONFIDENCE_GATE: gate( + GateType.CONFIDENCE_GATE, + GateStatus.PASSED, + 'Konfidenzwert 0.72 liegt über dem Mindestschwellenwert von 0.60.', + [ + { label: 'Konfidenz ≥ 0.60', passed: true, value: '0.72' }, + { label: 'Konfidenz ≥ 0.75 (hoch)', passed: false, value: '0.72', note: 'Mittlere Konfidenz' }, + ], + ), + SENSITIVITY_GATE: gate( + GateType.SENSITIVITY_GATE, + GateStatus.PASSED, + 'Sensitivitätsstufe INTERNAL ist für interne Feed-Nutzung zulässig.', + [ + { label: 'Nicht CONFIDENTIAL', passed: true, value: 'INTERNAL' }, + { label: 'Nicht RESTRICTED', passed: true }, + ], + ), + REVIEW_GATE: gate( + GateType.REVIEW_GATE, + GateStatus.PENDING, + 'Signal wartet auf manuellen Analyst-Review.', + [ + { label: 'Review angefordert', passed: true }, + { label: 'Review abgeschlossen', passed: false }, + ], + 'Analyst-Review durchführen und Signal genehmigen oder ablehnen.', + ), + MATCHABILITY_GATE: gate( + GateType.MATCHABILITY_GATE, + GateStatus.PENDING, + 'Matchbarkeit kann erst nach abgeschlossenem Review bewertet werden.', + [ + { label: 'Asset-Typ definiert', passed: true, value: 'OFFICE' }, + { label: 'Standort definiert', passed: true, value: 'Zürich' }, + { label: 'Zeithorizont definiert', passed: false }, + { label: 'Geschäftsrelevanz vorhanden', passed: true }, + ], + 'Review abschliessen, dann Matchbarkeit neu bewerten.', + ), + FEED_ELIGIBILITY_GATE: gate( + GateType.FEED_ELIGIBILITY_GATE, + GateStatus.BLOCKED, + 'Feed-Eignung blockiert: Review-Gate noch ausstehend.', + [ + { label: 'Review-Gate bestanden', passed: false }, + { label: 'Matchbarkeits-Gate bestanden', passed: false }, + ], + 'Review und Matchbarkeits-Prüfung abschliessen.', + ), + }, +} + +// ── sig-002: STAGE_5_APPROVED_FUTURE ───────────────────────────────────────── +const sig002Pipeline: PipelineState = { + signalId: 'sig-002', + currentStage: PipelineStage.STAGE_5_APPROVED_FUTURE, + overallEligible: true, + publishedToFutureAvailability: false, + gates: { + EVIDENCE_GATE: gate( + GateType.EVIDENCE_GATE, + GateStatus.PASSED, + 'Baugesuchdokument als verlässliche Primärquelle vorhanden.', + [ + { label: 'Evidenz vorhanden', passed: true, value: '1 Stück' }, + { label: 'Quelle angegeben', passed: true, value: 'Baugesuchregister BS' }, + { label: 'Inhalt nicht leer', passed: true }, + ], + ), + CONFIDENCE_GATE: gate( + GateType.CONFIDENCE_GATE, + GateStatus.PASSED, + 'Konfidenzwert 0.88 überschreitet den hohen Schwellenwert von 0.75.', + [ + { label: 'Konfidenz ≥ 0.60', passed: true, value: '0.88' }, + { label: 'Konfidenz ≥ 0.75 (hoch)', passed: true, value: '0.88' }, + ], + ), + SENSITIVITY_GATE: gate( + GateType.SENSITIVITY_GATE, + GateStatus.PASSED, + 'Öffentliche Quelle – keine Einschränkungen.', + [ + { label: 'Nicht CONFIDENTIAL', passed: true, value: 'PUBLIC' }, + { label: 'Nicht RESTRICTED', passed: true }, + ], + ), + REVIEW_GATE: gate( + GateType.REVIEW_GATE, + GateStatus.PASSED, + 'Signal durch Analyst M. Huber am 12.05.2026 genehmigt.', + [ + { label: 'Review angefordert', passed: true }, + { label: 'Review abgeschlossen', passed: true, value: 'M. Huber, 12.05.2026' }, + { label: 'Genehmigt', passed: true }, + ], + ), + MATCHABILITY_GATE: gate( + GateType.MATCHABILITY_GATE, + GateStatus.PASSED, + 'Alle Pflichtfelder für Match-Engine vorhanden.', + [ + { label: 'Asset-Typ definiert', passed: true, value: 'OFFICE, LOGISTICS, RETAIL' }, + { label: 'Standort definiert', passed: true, value: 'Basel' }, + { label: 'Zeithorizont definiert', passed: true, value: 'Q1 2026' }, + { label: 'Geschäftsrelevanz vorhanden', passed: true }, + ], + ), + FEED_ELIGIBILITY_GATE: gate( + GateType.FEED_ELIGIBILITY_GATE, + GateStatus.PASSED, + 'Alle Gates bestanden – Signal ist feed-fähig.', + [ + { label: 'Review-Gate bestanden', passed: true }, + { label: 'Matchbarkeits-Gate bestanden', passed: true }, + { label: 'Sensitivität zulässig', passed: true, value: 'PUBLIC' }, + ], + ), + }, +} + +// ── sig-003: STAGE_3_ENRICHED ───────────────────────────────────────────────── +const sig003Pipeline: PipelineState = { + signalId: 'sig-003', + currentStage: PipelineStage.STAGE_3_ENRICHED, + overallEligible: false, + publishedToFutureAvailability: false, + gates: { + EVIDENCE_GATE: gate( + GateType.EVIDENCE_GATE, + GateStatus.PASSED, + 'LinkedIn-Datenauszug als Evidenz vorhanden.', + [ + { label: 'Evidenz vorhanden', passed: true, value: '1 Stück' }, + { label: 'Quelle angegeben', passed: true, value: 'LinkedIn Hiring Data' }, + { label: 'Inhalt nicht leer', passed: true }, + ], + ), + CONFIDENCE_GATE: gate( + GateType.CONFIDENCE_GATE, + GateStatus.PENDING, + 'Konfidenzwert 0.61 liegt im Grenzbereich (0.60–0.65). Zusätzliche Validierung empfohlen.', + [ + { label: 'Konfidenz ≥ 0.60', passed: true, value: '0.61', note: 'Grenzbereich' }, + { label: 'Konfidenz ≥ 0.75 (hoch)', passed: false, value: '0.61' }, + ], + 'Unternehmensidentität über HR-Netzwerke verifizieren, um Konfidenz zu erhöhen.', + ), + SENSITIVITY_GATE: gate( + GateType.SENSITIVITY_GATE, + GateStatus.PASSED, + 'Öffentliche Quelle – keine Einschränkungen.', + [ + { label: 'Nicht CONFIDENTIAL', passed: true, value: 'PUBLIC' }, + { label: 'Nicht RESTRICTED', passed: true }, + ], + ), + REVIEW_GATE: gate( + GateType.REVIEW_GATE, + GateStatus.PENDING, + 'Signal noch nicht für Review eingereicht.', + [ + { label: 'Review angefordert', passed: false }, + { label: 'Review abgeschlossen', passed: false }, + ], + 'Signal für Analyst-Review einreichen nach Konfidenz-Verbesserung.', + ), + MATCHABILITY_GATE: gate( + GateType.MATCHABILITY_GATE, + GateStatus.PENDING, + 'Matchbarkeit noch nicht bewertet – Anreicherung läuft.', + [ + { label: 'Asset-Typ definiert', passed: true, value: 'OFFICE' }, + { label: 'Standort definiert', passed: true, value: 'Zug' }, + { label: 'Zeithorizont definiert', passed: false }, + { label: 'Geschäftsrelevanz vorhanden', passed: false, note: 'Unternehmensname unbekannt' }, + ], + ), + FEED_ELIGIBILITY_GATE: gate( + GateType.FEED_ELIGIBILITY_GATE, + GateStatus.BLOCKED, + 'Feed-Eignung blockiert: Mehrere vorgelagerte Gates ausstehend.', + [ + { label: 'Review-Gate bestanden', passed: false }, + { label: 'Matchbarkeits-Gate bestanden', passed: false }, + ], + ), + }, +} + +// ── sig-004: STAGE_4_REVIEW_CANDIDATE ───────────────────────────────────────── +const sig004Pipeline: PipelineState = { + signalId: 'sig-004', + currentStage: PipelineStage.STAGE_4_REVIEW_CANDIDATE, + overallEligible: false, + publishedToFutureAvailability: false, + gates: { + EVIDENCE_GATE: gate( + GateType.EVIDENCE_GATE, + GateStatus.PASSED, + 'Mietvertragsdokument als primäre Evidenz vorhanden.', + [ + { label: 'Evidenz vorhanden', passed: true, value: '1 Stück' }, + { label: 'Quelle angegeben', passed: true, value: 'Internes ERP' }, + { label: 'Inhalt nicht leer', passed: true }, + ], + ), + CONFIDENCE_GATE: gate( + GateType.CONFIDENCE_GATE, + GateStatus.PASSED, + 'Konfidenzwert 0.97 – sehr hohe Verlässlichkeit (interne Vertragsdaten).', + [ + { label: 'Konfidenz ≥ 0.60', passed: true, value: '0.97' }, + { label: 'Konfidenz ≥ 0.75 (hoch)', passed: true, value: '0.97' }, + ], + ), + SENSITIVITY_GATE: gate( + GateType.SENSITIVITY_GATE, + GateStatus.FAILED, + 'Sensitivitätsstufe CONFIDENTIAL: Signal darf nicht im Demand Feed erscheinen. Mieteridentität ist schützenswert.', + [ + { label: 'Nicht CONFIDENTIAL', passed: false, value: 'CONFIDENTIAL', note: 'Interne Vertragsdaten – nur für Property Manager' }, + { label: 'Nicht RESTRICTED', passed: true }, + ], + 'Daten anonymisieren oder auf aggregierter Ebene veröffentlichen.', + ), + REVIEW_GATE: gate( + GateType.REVIEW_GATE, + GateStatus.PENDING, + 'Review angefordert – Sensitivitäts-Gate muss zuerst adressiert werden.', + [ + { label: 'Review angefordert', passed: true }, + { label: 'Review abgeschlossen', passed: false }, + ], + 'Sensitivitätsproblem lösen, dann Review abschliessen.', + ), + MATCHABILITY_GATE: gate( + GateType.MATCHABILITY_GATE, + GateStatus.PENDING, + 'Matchbarkeit ausstehend – zuerst Sensitivitätsproblem lösen.', + [ + { label: 'Asset-Typ definiert', passed: true, value: 'OFFICE' }, + { label: 'Standort definiert', passed: true, value: 'Zürich-West' }, + { label: 'Zeithorizont definiert', passed: true, value: '03/2026' }, + { label: 'Geschäftsrelevanz vorhanden', passed: true }, + ], + ), + FEED_ELIGIBILITY_GATE: gate( + GateType.FEED_ELIGIBILITY_GATE, + GateStatus.BLOCKED, + 'Feed-Eignung blockiert: Sensitivitäts-Gate fehlgeschlagen.', + [ + { label: 'Sensitivitäts-Gate bestanden', passed: false, value: 'CONFIDENTIAL' }, + { label: 'Review-Gate bestanden', passed: false }, + ], + 'Anonymisierung der Mieterdaten durchführen.', + ), + }, +} + +// ── sig-005: STAGE_2_NORMALIZED ─────────────────────────────────────────────── +const sig005Pipeline: PipelineState = { + signalId: 'sig-005', + currentStage: PipelineStage.STAGE_2_NORMALIZED, + overallEligible: false, + publishedToFutureAvailability: false, + gates: { + EVIDENCE_GATE: gate( + GateType.EVIDENCE_GATE, + GateStatus.PASSED, + 'Handelsregistermutation als verlässliche Primärquelle.', + [ + { label: 'Evidenz vorhanden', passed: true, value: '1 Stück' }, + { label: 'Quelle angegeben', passed: true, value: 'Zefix – Handelsregister Schweiz' }, + { label: 'Inhalt nicht leer', passed: true }, + ], + ), + CONFIDENCE_GATE: gate( + GateType.CONFIDENCE_GATE, + GateStatus.PENDING, + 'Konfidenzwert 0.63 – Sitzverlegung bestätigt, aber Flächenbedarf am Zielort noch nicht validiert.', + [ + { label: 'Konfidenz ≥ 0.60', passed: true, value: '0.63', note: 'Grenzbereich' }, + { label: 'Konfidenz ≥ 0.75 (hoch)', passed: false, value: '0.63' }, + ], + 'Flächenbedarf in Kloten durch Direktkontakt oder weitere Quellenrecherche bestätigen.', + ), + SENSITIVITY_GATE: gate( + GateType.SENSITIVITY_GATE, + GateStatus.PASSED, + 'Öffentliches Handelsregister – keine Sensitivitätsbedenken.', + [ + { label: 'Nicht CONFIDENTIAL', passed: true, value: 'PUBLIC' }, + { label: 'Nicht RESTRICTED', passed: true }, + ], + ), + REVIEW_GATE: gate( + GateType.REVIEW_GATE, + GateStatus.PENDING, + 'Signal noch in Normalisierungsphase – kein Review angefordert.', + [ + { label: 'Review angefordert', passed: false }, + { label: 'Review abgeschlossen', passed: false }, + ], + 'Anreicherung abschliessen, dann für Review einreichen.', + ), + MATCHABILITY_GATE: gate( + GateType.MATCHABILITY_GATE, + GateStatus.PENDING, + 'Anreicherung noch nicht abgeschlossen.', + [ + { label: 'Asset-Typ definiert', passed: true, value: 'LOGISTICS' }, + { label: 'Standort definiert', passed: true, value: 'Kloten' }, + { label: 'Zeithorizont definiert', passed: false }, + { label: 'Geschäftsrelevanz vorhanden', passed: false }, + ], + ), + FEED_ELIGIBILITY_GATE: gate( + GateType.FEED_ELIGIBILITY_GATE, + GateStatus.BLOCKED, + 'Feed-Eignung blockiert: Signal befindet sich noch in der Normalisierungsphase.', + [ + { label: 'Review-Gate bestanden', passed: false }, + { label: 'Matchbarkeits-Gate bestanden', passed: false }, + ], + ), + }, +} + +// ── sig-006: STAGE_1_RAW_EVIDENCE ──────────────────────────────────────────── +const sig006Pipeline: PipelineState = { + signalId: 'sig-006', + currentStage: PipelineStage.STAGE_1_RAW_EVIDENCE, + overallEligible: false, + publishedToFutureAvailability: false, + gates: { + EVIDENCE_GATE: gate( + GateType.EVIDENCE_GATE, + GateStatus.PASSED, + 'Inserat auf Immoscout24 als Primärquelle vorhanden.', + [ + { label: 'Evidenz vorhanden', passed: true, value: '1 Stück' }, + { label: 'Quelle angegeben', passed: true, value: 'Immoscout24' }, + { label: 'Inhalt nicht leer', passed: true }, + ], + ), + CONFIDENCE_GATE: gate( + GateType.CONFIDENCE_GATE, + GateStatus.PASSED, + 'Konfidenzwert 0.83 – direkte Nachfrage-Ausschreibung mit hoher Relevanz.', + [ + { label: 'Konfidenz ≥ 0.60', passed: true, value: '0.83' }, + { label: 'Konfidenz ≥ 0.75 (hoch)', passed: true, value: '0.83' }, + ], + ), + SENSITIVITY_GATE: gate( + GateType.SENSITIVITY_GATE, + GateStatus.PASSED, + 'Öffentliche Plattform – keine Einschränkungen.', + [ + { label: 'Nicht CONFIDENTIAL', passed: true, value: 'PUBLIC' }, + { label: 'Nicht RESTRICTED', passed: true }, + ], + ), + REVIEW_GATE: gate( + GateType.REVIEW_GATE, + GateStatus.PENDING, + 'Signal soeben erkannt – Normalisierung und Anreicherung noch ausstehend.', + [ + { label: 'Review angefordert', passed: false }, + { label: 'Review abgeschlossen', passed: false }, + ], + 'Signal normalisieren und anreichern, dann für Review einreichen.', + ), + MATCHABILITY_GATE: gate( + GateType.MATCHABILITY_GATE, + GateStatus.PENDING, + 'Matchbarkeit noch nicht bewertet – Signal in früher Phase.', + [ + { label: 'Asset-Typ definiert', passed: true, value: 'LOGISTICS' }, + { label: 'Standort definiert', passed: true, value: 'Winterthur' }, + { label: 'Zeithorizont definiert', passed: true, value: 'September 2025' }, + { label: 'Geschäftsrelevanz vorhanden', passed: false }, + ], + ), + FEED_ELIGIBILITY_GATE: gate( + GateType.FEED_ELIGIBILITY_GATE, + GateStatus.BLOCKED, + 'Feed-Eignung blockiert: Signal in Rohphase – mehrere Stufen offen.', + [ + { label: 'Review-Gate bestanden', passed: false }, + { label: 'Matchbarkeits-Gate bestanden', passed: false }, + ], + ), + }, +} + +// ── sig-007: STAGE_3_ENRICHED ───────────────────────────────────────────────── +const sig007Pipeline: PipelineState = { + signalId: 'sig-007', + currentStage: PipelineStage.STAGE_3_ENRICHED, + overallEligible: false, + publishedToFutureAvailability: false, + gates: { + EVIDENCE_GATE: gate( + GateType.EVIDENCE_GATE, + GateStatus.PASSED, + 'SBB-Medienmitteilung als verlässliche behördliche Quelle.', + [ + { label: 'Evidenz vorhanden', passed: true, value: '1 Stück' }, + { label: 'Quelle angegeben', passed: true, value: 'SBB Medienmitteilung' }, + { label: 'Inhalt nicht leer', passed: true }, + ], + ), + CONFIDENCE_GATE: gate( + GateType.CONFIDENCE_GATE, + GateStatus.PASSED, + 'Konfidenzwert 0.79 – behördliche Bestätigung vorhanden.', + [ + { label: 'Konfidenz ≥ 0.60', passed: true, value: '0.79' }, + { label: 'Konfidenz ≥ 0.75 (hoch)', passed: true, value: '0.79' }, + ], + ), + SENSITIVITY_GATE: gate( + GateType.SENSITIVITY_GATE, + GateStatus.PASSED, + 'Öffentliche SBB-Mitteilung – keine Sensitivitätsbedenken.', + [ + { label: 'Nicht CONFIDENTIAL', passed: true, value: 'PUBLIC' }, + { label: 'Nicht RESTRICTED', passed: true }, + ], + ), + REVIEW_GATE: gate( + GateType.REVIEW_GATE, + GateStatus.PENDING, + 'Matchbarkeits-Problem muss zuerst gelöst werden.', + [ + { label: 'Review angefordert', passed: false }, + { label: 'Review abgeschlossen', passed: false }, + ], + 'Matchbarkeits-Frage klären (kein direktes Verfügbarkeitssignal), dann Review starten.', + ), + MATCHABILITY_GATE: gate( + GateType.MATCHABILITY_GATE, + GateStatus.FAILED, + 'Signal Typ PROJECT_DEVELOPMENT ohne direktes Verfügbarkeitssignal – kein konkreter Flächenbedarf erkennbar.', + [ + { label: 'Asset-Typ definiert', passed: true, value: 'OFFICE, PRODUCTION' }, + { label: 'Standort definiert', passed: true, value: 'Schlieren' }, + { label: 'Zeithorizont definiert', passed: true, value: 'Dezember 2027' }, + { label: 'Direktes Verfügbarkeitssignal', passed: false, note: 'Infrastrukturverbesserung ohne konkreten Flächenbedarf' }, + ], + 'Signal als strategischen Kontext-Input klassifizieren oder konkreten Bedarf nachweisen.', + ), + FEED_ELIGIBILITY_GATE: gate( + GateType.FEED_ELIGIBILITY_GATE, + GateStatus.BLOCKED, + 'Feed-Eignung blockiert: Matchbarkeits-Gate fehlgeschlagen.', + [ + { label: 'Matchbarkeits-Gate bestanden', passed: false }, + { label: 'Review-Gate bestanden', passed: false }, + ], + 'Signal als strategischen Input behandeln oder Matchbarkeit nachweisen.', + ), + }, +} + +// ── sig-008: STAGE_6_MATCHABLE_RESULT ──────────────────────────────────────── +const sig008Pipeline: PipelineState = { + signalId: 'sig-008', + currentStage: PipelineStage.STAGE_6_MATCHABLE_RESULT, + overallEligible: true, + publishedToFutureAvailability: true, + publishedAt: '2026-05-13T11:00:00Z', + feedDisclaimer: 'Nur in aggregierter Form – Mieteridentität anonymisiert. Kein Rückschluss auf Vertragsparteien möglich.', + gates: { + EVIDENCE_GATE: gate( + GateType.EVIDENCE_GATE, + GateStatus.PASSED, + 'Eigene Marktbeobachtung mit Vor-Ort-Begehung dokumentiert.', + [ + { label: 'Evidenz vorhanden', passed: true, value: '1 Stück' }, + { label: 'Quelle angegeben', passed: true, value: 'Eigene Beobachtung – Marktanalyse' }, + { label: 'Inhalt nicht leer', passed: true }, + ], + ), + CONFIDENCE_GATE: gate( + GateType.CONFIDENCE_GATE, + GateStatus.PASSED, + 'Konfidenzwert 0.68 – ausreichend für interne Verarbeitung.', + [ + { label: 'Konfidenz ≥ 0.60', passed: true, value: '0.68' }, + { label: 'Konfidenz ≥ 0.75 (hoch)', passed: false, value: '0.68', note: 'Mittlere Konfidenz akzeptiert' }, + ], + ), + SENSITIVITY_GATE: gate( + GateType.SENSITIVITY_GATE, + GateStatus.PASSED, + 'Sensitivitätsstufe INTERNAL mit Anonymisierungsauflage zulässig.', + [ + { label: 'Nicht CONFIDENTIAL', passed: true, value: 'INTERNAL' }, + { label: 'Nicht RESTRICTED', passed: true }, + { label: 'Anonymisierungsauflage gesetzt', passed: true }, + ], + ), + REVIEW_GATE: gate( + GateType.REVIEW_GATE, + GateStatus.PASSED, + 'Signal durch Analyst A. Müller am 13.05.2026 genehmigt und zur Publikation freigegeben.', + [ + { label: 'Review angefordert', passed: true }, + { label: 'Review abgeschlossen', passed: true, value: 'A. Müller, 13.05.2026' }, + { label: 'Genehmigt', passed: true }, + ], + ), + MATCHABILITY_GATE: gate( + GateType.MATCHABILITY_GATE, + GateStatus.PASSED, + 'Alle notwendigen Felder für Match-Engine vorhanden.', + [ + { label: 'Asset-Typ definiert', passed: true, value: 'OFFICE' }, + { label: 'Standort definiert', passed: true, value: 'Lausanne' }, + { label: 'Zeithorizont definiert', passed: true, value: 'Sofort verfügbar (3+ Monate leer)' }, + { label: 'Geschäftsrelevanz vorhanden', passed: true }, + ], + ), + FEED_ELIGIBILITY_GATE: gate( + GateType.FEED_ELIGIBILITY_GATE, + GateStatus.PASSED, + 'Signal im Future Availability Feed publiziert – anonymisiert und aggregiert.', + [ + { label: 'Review-Gate bestanden', passed: true }, + { label: 'Matchbarkeits-Gate bestanden', passed: true }, + { label: 'Anonymisierungsauflage umgesetzt', passed: true }, + { label: 'Im Feed publiziert', passed: true, value: '13.05.2026' }, + ], + ), + }, +} + +export const MOCK_PIPELINE_STATES: PipelineState[] = [ + sig001Pipeline, + sig002Pipeline, + sig003Pipeline, + sig004Pipeline, + sig005Pipeline, + sig006Pipeline, + sig007Pipeline, + sig008Pipeline, +] + +// ── Audit Trails ────────────────────────────────────────────────────────────── + +export const MOCK_AUDIT_TRAILS: AuditTrailEntry[] = [ + // sig-001 + { + id: 'audit-001-1', + signalId: 'sig-001', + timestamp: '2025-05-10T09:23:00Z', + stage: PipelineStage.STAGE_1_RAW_EVIDENCE, + action: 'Signal erkannt', + performedBy: 'System / NZZ-Connector', + details: 'Signal automatisch aus NZZ-Medienbericht extrahiert. Rohtext gespeichert.', + }, + { + id: 'audit-001-2', + signalId: 'sig-001', + timestamp: '2025-05-10T10:05:00Z', + stage: PipelineStage.STAGE_2_NORMALIZED, + action: 'Normalisierung abgeschlossen', + performedBy: 'System / NLP-Pipeline', + details: 'Felder normalisiert: Standort "Zürich ZH", Asset-Typ OFFICE, Signaltyp EXPANSION.', + }, + { + id: 'audit-001-3', + signalId: 'sig-001', + timestamp: '2025-05-11T08:00:00Z', + stage: PipelineStage.STAGE_3_ENRICHED, + action: 'Anreicherung abgeschlossen', + performedBy: 'System / Enrichment-Engine', + details: 'Entitäten extrahiert: UBS AG (Konfidenz 0.98), Zürich (0.95), Ende 2025 (0.87). Konfidenzwert: 0.72.', + }, + { + id: 'audit-001-4', + signalId: 'sig-001', + timestamp: '2025-05-11T08:05:00Z', + stage: PipelineStage.STAGE_4_REVIEW_CANDIDATE, + action: 'Zur Prüfung eingereicht', + performedBy: 'System / Gate-Evaluator', + details: 'Evidenz-Gate, Konfidenz-Gate und Sensitivitäts-Gate bestanden. Review-Gate offen – wartet auf Analyst.', + gateType: GateType.REVIEW_GATE, + }, + + // sig-002 + { + id: 'audit-002-1', + signalId: 'sig-002', + timestamp: '2025-05-08T14:00:00Z', + stage: PipelineStage.STAGE_1_RAW_EVIDENCE, + action: 'Signal erkannt', + performedBy: 'System / Baugesuch-Connector', + details: 'Baugesuch Nr. BS-2025-0312 aus öffentlichem Register extrahiert.', + }, + { + id: 'audit-002-2', + signalId: 'sig-002', + timestamp: '2025-05-08T15:00:00Z', + stage: PipelineStage.STAGE_3_ENRICHED, + action: 'Normalisierung und Anreicherung', + performedBy: 'System / NLP-Pipeline', + details: 'Alle Felder normalisiert und angereichert. Standort Basel-Nord, Asset-Typen: OFFICE, LOGISTICS, RETAIL. Konfidenz: 0.88.', + }, + { + id: 'audit-002-3', + signalId: 'sig-002', + timestamp: '2025-05-12T09:30:00Z', + stage: PipelineStage.STAGE_4_REVIEW_CANDIDATE, + action: 'Analyst-Review gestartet', + performedBy: 'M. Huber', + details: 'Review-Kandidat angenommen. Öffentliche Quelle mit hoher Verlässlichkeit.', + }, + { + id: 'audit-002-4', + signalId: 'sig-002', + timestamp: '2025-05-12T09:45:00Z', + stage: PipelineStage.STAGE_5_APPROVED_FUTURE, + action: 'Signal genehmigt', + performedBy: 'M. Huber', + details: 'Signal als Future Availability genehmigt. Alle Gates bestanden. Bereit zur Feed-Publikation.', + gateType: GateType.REVIEW_GATE, + }, + + // sig-003 + { + id: 'audit-003-1', + signalId: 'sig-003', + timestamp: '2025-05-09T11:30:00Z', + stage: PipelineStage.STAGE_1_RAW_EVIDENCE, + action: 'Signal erkannt', + performedBy: 'System / LinkedIn-Connector', + details: 'Stellenwachstumssignal aus LinkedIn-Daten extrahiert. 80 neue Stellenausschreibungen in 6 Monaten.', + }, + { + id: 'audit-003-2', + signalId: 'sig-003', + timestamp: '2025-05-09T12:00:00Z', + stage: PipelineStage.STAGE_2_NORMALIZED, + action: 'Normalisierung abgeschlossen', + performedBy: 'System / NLP-Pipeline', + details: 'Standort Zug, Asset-Typ OFFICE, Signaltyp EXPANSION normalisiert. Unternehmensname nicht öffentlich.', + }, + { + id: 'audit-003-3', + signalId: 'sig-003', + timestamp: '2025-05-11T14:00:00Z', + stage: PipelineStage.STAGE_3_ENRICHED, + action: 'Anreicherung abgeschlossen', + performedBy: 'System / Enrichment-Engine', + details: 'Konfidenzwert 0.61 – Grenzbereich. Unternehmensverifizierung empfohlen. Weiterer Review nötig.', + }, + + // sig-004 + { + id: 'audit-004-1', + signalId: 'sig-004', + timestamp: '2025-05-05T08:00:00Z', + stage: PipelineStage.STAGE_1_RAW_EVIDENCE, + action: 'Signal erkannt', + performedBy: 'System / ERP-Connector', + details: 'Vertragslaufdaten aus internem ERP importiert. Vertrag V-2019-0481, Ablauf 31.03.2026.', + }, + { + id: 'audit-004-2', + signalId: 'sig-004', + timestamp: '2025-05-05T08:30:00Z', + stage: PipelineStage.STAGE_3_ENRICHED, + action: 'Normalisierung und Anreicherung', + performedBy: 'System / NLP-Pipeline', + details: 'Alle Felder normalisiert. Hohe Konfidenz (0.97) da Primärquelle. Sensitivität CONFIDENTIAL erkannt.', + }, + { + id: 'audit-004-3', + signalId: 'sig-004', + timestamp: '2025-05-10T10:00:00Z', + stage: PipelineStage.STAGE_4_REVIEW_CANDIDATE, + action: 'Sensitivitäts-Warnung gesetzt', + performedBy: 'System / Gate-Evaluator', + details: 'Sensitivitäts-Gate fehlgeschlagen: CONFIDENTIAL. Signal zur Review eingereicht mit Anonymisierungsauflage.', + gateType: GateType.SENSITIVITY_GATE, + }, + { + id: 'audit-004-4', + signalId: 'sig-004', + timestamp: '2025-05-10T10:05:00Z', + stage: PipelineStage.STAGE_4_REVIEW_CANDIDATE, + action: 'Property Manager benachrichtigt', + performedBy: 'System / Notification-Service', + details: 'Property Manager über auslaufenden Grossmietvertrag informiert. Verlängerungsgespräch empfohlen.', + }, + + // sig-005 + { + id: 'audit-005-1', + signalId: 'sig-005', + timestamp: '2025-05-07T16:20:00Z', + stage: PipelineStage.STAGE_1_RAW_EVIDENCE, + action: 'Signal erkannt', + performedBy: 'System / Handelsregister-Connector', + details: 'Sitzverlegung aus Zefix extrahiert: 3014 Bern → 8302 Kloten, eingetragen 07.05.2025.', + }, + { + id: 'audit-005-2', + signalId: 'sig-005', + timestamp: '2025-05-07T17:00:00Z', + stage: PipelineStage.STAGE_2_NORMALIZED, + action: 'Normalisierung abgeschlossen', + performedBy: 'System / NLP-Pipeline', + details: 'Standort Kloten (Zielort), Asset-Typ LOGISTICS normalisiert. Flächenbedarf noch nicht validiert.', + }, + { + id: 'audit-005-3', + signalId: 'sig-005', + timestamp: '2025-05-08T09:00:00Z', + stage: PipelineStage.STAGE_2_NORMALIZED, + action: 'Anreicherung gestartet', + performedBy: 'System / Enrichment-Engine', + details: 'Konfidenzwert 0.63 – Sitzverlegung bestätigt, konkreter Flächenbedarf am Zielort ausstehend.', + }, + + // sig-006 + { + id: 'audit-006-1', + signalId: 'sig-006', + timestamp: '2025-05-12T08:15:00Z', + stage: PipelineStage.STAGE_1_RAW_EVIDENCE, + action: 'Signal erkannt', + performedBy: 'System / Immoscout-Connector', + details: 'Neue Suchanzeige auf Immoscout24 erkannt: 800 m² Lagerfläche Winterthur, sofort gesucht.', + }, + { + id: 'audit-006-2', + signalId: 'sig-006', + timestamp: '2025-05-12T08:30:00Z', + stage: PipelineStage.STAGE_1_RAW_EVIDENCE, + action: 'Evidenz-Gate bestanden', + performedBy: 'System / Gate-Evaluator', + details: 'Evidenz-Gate und Konfidenz-Gate (0.83) bestanden. Signal in Pipeline aufgenommen, Normalisierung startet.', + gateType: GateType.EVIDENCE_GATE, + }, + + // sig-007 + { + id: 'audit-007-1', + signalId: 'sig-007', + timestamp: '2025-05-06T10:00:00Z', + stage: PipelineStage.STAGE_1_RAW_EVIDENCE, + action: 'Signal erkannt', + performedBy: 'System / SBB-Feed-Connector', + details: 'SBB-Medienmitteilung zur neuen Haltestelle Schlieren-West 2027 automatisch verarbeitet.', + }, + { + id: 'audit-007-2', + signalId: 'sig-007', + timestamp: '2025-05-06T11:00:00Z', + stage: PipelineStage.STAGE_2_NORMALIZED, + action: 'Normalisierung abgeschlossen', + performedBy: 'System / NLP-Pipeline', + details: 'Standort Schlieren, Asset-Typen OFFICE und PRODUCTION, Zeithorizont Dezember 2027 normalisiert.', + }, + { + id: 'audit-007-3', + signalId: 'sig-007', + timestamp: '2025-05-10T15:00:00Z', + stage: PipelineStage.STAGE_3_ENRICHED, + action: 'Matchbarkeits-Problem identifiziert', + performedBy: 'System / Gate-Evaluator', + details: 'Matchbarkeits-Gate fehlgeschlagen: PROJECT_DEVELOPMENT ohne direktes Verfügbarkeitssignal. Kein konkreter Flächenbedarf nachweisbar.', + gateType: GateType.MATCHABILITY_GATE, + }, + { + id: 'audit-007-4', + signalId: 'sig-007', + timestamp: '2025-05-10T15:05:00Z', + stage: PipelineStage.STAGE_3_ENRICHED, + action: 'Analyst-Notiz hinzugefügt', + performedBy: 'K. Bauer', + details: 'Infrastrukturverbesserung dokumentiert. Empfehlung: Als strategischen Kontext-Input behandeln, nicht als direktes Matchingobjekt.', + }, + + // sig-008 + { + id: 'audit-008-1', + signalId: 'sig-008', + timestamp: '2025-05-03T14:30:00Z', + stage: PipelineStage.STAGE_1_RAW_EVIDENCE, + action: 'Signal manuell erfasst', + performedBy: 'A. Müller', + details: 'Vor-Ort-Begehung Tour de Berne, Lausanne. 4. OG leer stehend, keine Vermarktung erkennbar.', + }, + { + id: 'audit-008-2', + signalId: 'sig-008', + timestamp: '2025-05-03T15:00:00Z', + stage: PipelineStage.STAGE_3_ENRICHED, + action: 'Normalisierung und Anreicherung', + performedBy: 'System / NLP-Pipeline', + details: 'Standort Lausanne, Asset-Typ OFFICE, Signaltyp POSSIBLE_MOVE_OUT normalisiert. Konfidenz 0.68.', + }, + { + id: 'audit-008-3', + signalId: 'sig-008', + timestamp: '2025-05-13T10:30:00Z', + stage: PipelineStage.STAGE_4_REVIEW_CANDIDATE, + action: 'Analyst-Review abgeschlossen', + performedBy: 'A. Müller', + details: 'Signal geprüft und genehmigt. Anonymisierungsauflage gesetzt: Mieteridentität darf nicht kommuniziert werden.', + gateType: GateType.REVIEW_GATE, + }, + { + id: 'audit-008-4', + signalId: 'sig-008', + timestamp: '2025-05-13T11:00:00Z', + stage: PipelineStage.STAGE_6_MATCHABLE_RESULT, + action: 'In Future Availability publiziert', + performedBy: 'A. Müller', + details: 'Signal als Future Availability "fs-lausanne-001" in den Feed übertragen. Anonymisierter Disclaimer aktiv.', + gateType: GateType.FEED_ELIGIBILITY_GATE, + }, +] diff --git a/src/pages/ops/SignalPipeline.tsx b/src/pages/ops/SignalPipeline.tsx new file mode 100644 index 0000000..3a3395b --- /dev/null +++ b/src/pages/ops/SignalPipeline.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react' +import { Box } from '@mui/material' +import { PageHeader } from '../../components/layout' +import { SignalInbox, SignalPipelineView, MarketSignalEmptyState } from '../../components/ops' +import { useMarketSignals } from '../../hooks/useMarketSignals' +import type { MarketSignalFilters } from '../../domain/marketSignal' + +export default function SignalPipeline() { + 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 ( + + + + + + + + {selectedSignal + ? + : + } + + + + ) +} diff --git a/src/provider/ISignalPipelineProvider.ts b/src/provider/ISignalPipelineProvider.ts new file mode 100644 index 0000000..7b4d48b --- /dev/null +++ b/src/provider/ISignalPipelineProvider.ts @@ -0,0 +1,8 @@ +import type { PipelineState, AuditTrailEntry, GateType } from '../domain/signalPipeline' + +export interface ISignalPipelineProvider { + getPipelineState(signalId: string): Promise + evaluateGate(signalId: string, gateType: GateType): Promise + getAuditTrail(signalId: string): Promise + publishToFutureAvailability(signalId: string): Promise +} diff --git a/src/provider/MockupSignalPipelineProvider.ts b/src/provider/MockupSignalPipelineProvider.ts new file mode 100644 index 0000000..ec45a71 --- /dev/null +++ b/src/provider/MockupSignalPipelineProvider.ts @@ -0,0 +1,57 @@ +import type { ISignalPipelineProvider } from './ISignalPipelineProvider' +import type { PipelineState } from '../domain/signalPipeline' +import { GateStatus, PipelineStage } from '../domain/signalPipeline' +import type { GateType } from '../domain/signalPipeline' +import { MOCK_PIPELINE_STATES, MOCK_AUDIT_TRAILS } from '../mock-data/signalPipelines' + +let states: PipelineState[] = [...MOCK_PIPELINE_STATES] + +export const MockupSignalPipelineProvider: ISignalPipelineProvider = { + async getPipelineState(signalId) { + return states.find(s => s.signalId === signalId) ?? null + }, + async evaluateGate(signalId, gateType) { + const idx = states.findIndex(s => s.signalId === signalId) + if (idx === -1) throw new Error(`Pipeline state for ${signalId} not found`) + // Demo: mark gate as re-evaluated (no real logic change) + const updated: PipelineState = { + ...states[idx], + gates: { + ...states[idx].gates, + [gateType]: { + ...states[idx].gates[gateType as GateType], + evaluatedAt: new Date().toISOString(), + }, + }, + } + states[idx] = updated + return updated + }, + async getAuditTrail(signalId) { + return MOCK_AUDIT_TRAILS.filter(e => e.signalId === signalId) + .sort((a, b) => b.timestamp.localeCompare(a.timestamp)) + }, + async publishToFutureAvailability(signalId) { + const idx = states.findIndex(s => s.signalId === signalId) + if (idx === -1) throw new Error(`Pipeline state for ${signalId} not found`) + const now = new Date().toISOString() + states[idx] = { + ...states[idx], + publishedToFutureAvailability: true, + publishedAt: now, + currentStage: PipelineStage.STAGE_6_MATCHABLE_RESULT, + gates: { + ...states[idx].gates, + FEED_ELIGIBILITY_GATE: { + ...states[idx].gates.FEED_ELIGIBILITY_GATE, + status: GateStatus.PASSED, + reason: 'Signal manuell in Future Availability publiziert.', + evaluatedAt: now, + checks: [{ label: 'Manuell publiziert', passed: true, value: 'Ja' }], + }, + }, + overallEligible: true, + } + return states[idx] + }, +} diff --git a/src/services/signalPipelineService.ts b/src/services/signalPipelineService.ts new file mode 100644 index 0000000..ed4bf5a --- /dev/null +++ b/src/services/signalPipelineService.ts @@ -0,0 +1,24 @@ +import { MockupSignalPipelineProvider } from '../provider/MockupSignalPipelineProvider' +import type { PipelineState, AuditTrailEntry, GateType } from '../domain/signalPipeline' +import type { ItemResponse, ListResponse } from './types' + +const provider = MockupSignalPipelineProvider + +export const signalPipelineService = { + async getPipelineState(signalId: string): Promise> { + const data = await provider.getPipelineState(signalId) + return { data } + }, + async evaluateGate(signalId: string, gateType: GateType): Promise> { + const data = await provider.evaluateGate(signalId, gateType) + return { data } + }, + async getAuditTrail(signalId: string): Promise> { + const data = await provider.getAuditTrail(signalId) + return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } } + }, + async publishToFutureAvailability(signalId: string): Promise> { + const data = await provider.publishToFutureAvailability(signalId) + return { data } + }, +}