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 && (
+
+ : }
+ disabled={isPublishing}
+ onClick={() => publish(signal.id)}
+ sx={{
+ textTransform: 'none',
+ fontSize: '0.8rem',
+ bgcolor: '#1e3a5f',
+ '&:hover': { bgcolor: '#162d4a' },
+ }}
+ >
+ In Future Availability publizieren
+
+
+ )}
+
+
+
+
+ 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 }
+ },
+}