feat: F023 signal-to-match pipeline & shadow market decision flow
This commit is contained in:
@@ -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 (
|
||||
<Box sx={{ mt: 1.25 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
Konfidenz-Schwelle: {threshold}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: barColor, fontWeight: 600 }}>
|
||||
{pct}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ height: 6, bgcolor: 'rgba(148,163,184,0.2)', borderRadius: 3, overflow: 'hidden' }}>
|
||||
<Box
|
||||
sx={{
|
||||
height: '100%',
|
||||
width: `${pct}%`,
|
||||
bgcolor: barColor,
|
||||
borderRadius: 3,
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 0.25 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.65rem' }}>
|
||||
0%
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#d97706', fontSize: '0.65rem' }}>
|
||||
35%
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#15803d', fontSize: '0.65rem' }}>
|
||||
75%
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.65rem' }}>
|
||||
100%
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Paper variant="outlined" sx={{ mb: 1.5, borderRadius: 1.5 }}>
|
||||
<Box sx={{ p: 1.5, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Target size={14} color="#64748b" />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, fontSize: '0.8rem' }}>
|
||||
Konfidenz-Gate
|
||||
</Typography>
|
||||
<Box sx={{ ml: 'auto' }}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={GATE_STATUS_LABELS[gate.status]}
|
||||
sx={{ bgcolor: bg, color: fg, border: 'none', fontSize: '0.7rem', fontWeight: 600 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Divider />
|
||||
<Box sx={{ p: 1.5 }}>
|
||||
{gate.checks.map((check, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
{check.passed
|
||||
? <CheckCircle2 size={13} color="#15803d" />
|
||||
: <XCircle size={13} color="#dc2626" />
|
||||
}
|
||||
<Typography variant="caption">
|
||||
{check.label}
|
||||
{check.value && (
|
||||
<Box component="span" sx={{ color: 'text.secondary', ml: 0.5 }}>
|
||||
{check.value}
|
||||
</Box>
|
||||
)}
|
||||
{check.note && (
|
||||
<Box component="span" sx={{ color: '#a16207', ml: 0.5, fontStyle: 'italic' }}>
|
||||
({check.note})
|
||||
</Box>
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
<ConfidenceBar value={confidenceValue} />
|
||||
{gate.reason && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: 'text.secondary', mt: 1, display: 'block' }}
|
||||
>
|
||||
{gate.reason}
|
||||
</Typography>
|
||||
)}
|
||||
{gate.nextAction && gate.status !== GateStatus.PASSED && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, mt: 0.5 }}>
|
||||
<ChevronRight size={12} color="#1d4ed8" />
|
||||
<Typography variant="caption" sx={{ color: '#1d4ed8', display: 'block' }}>
|
||||
{gate.nextAction}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Paper variant="outlined" sx={{ mb: 1.5, borderRadius: 1.5 }}>
|
||||
<Box sx={{ p: 1.5, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<FileSearch size={14} color="#64748b" />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, fontSize: '0.8rem' }}>
|
||||
Evidenz-Gate
|
||||
</Typography>
|
||||
<Box sx={{ ml: 'auto' }}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={GATE_STATUS_LABELS[gate.status]}
|
||||
sx={{ bgcolor: bg, color: fg, border: 'none', fontSize: '0.7rem', fontWeight: 600 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Divider />
|
||||
<Box sx={{ p: 1.5 }}>
|
||||
{gate.checks.map((check, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
{check.passed
|
||||
? <CheckCircle2 size={13} color="#15803d" />
|
||||
: <XCircle size={13} color="#dc2626" />
|
||||
}
|
||||
<Typography variant="caption">
|
||||
{check.label}
|
||||
{check.value && (
|
||||
<Box component="span" sx={{ color: 'text.secondary', ml: 0.5 }}>
|
||||
{check.value}
|
||||
</Box>
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
{gate.reason && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: 'text.secondary', mt: 1, display: 'block' }}
|
||||
>
|
||||
{gate.reason}
|
||||
</Typography>
|
||||
)}
|
||||
{gate.nextAction && gate.status !== GateStatus.PASSED && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, mt: 0.5 }}>
|
||||
<ChevronRight size={12} color="#1d4ed8" />
|
||||
<Typography variant="caption" sx={{ color: '#1d4ed8', display: 'block' }}>
|
||||
{gate.nextAction}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Chip
|
||||
size="small"
|
||||
label="Feed-fähig"
|
||||
icon={<CheckCircle2 size={11} color="#15803d" />}
|
||||
sx={{
|
||||
bgcolor: 'rgba(22,163,74,0.1)',
|
||||
color: '#15803d',
|
||||
border: 'none',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
'& .MuiChip-icon': { ml: 0.75 },
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Chip
|
||||
size="small"
|
||||
label="Nicht Feed-fähig"
|
||||
icon={<MinusCircle size={11} color="#64748b" />}
|
||||
sx={{
|
||||
bgcolor: 'rgba(148,163,184,0.1)',
|
||||
color: '#64748b',
|
||||
border: 'none',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
'& .MuiChip-icon': { ml: 0.75 },
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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<string, LucideIcon> = {
|
||||
@@ -117,6 +118,7 @@ export function MarketSignalDetailPanel({ signal }: MarketSignalDetailPanelProps
|
||||
fontSize: '0.7rem',
|
||||
}}
|
||||
/>
|
||||
<FeedEligibilityBadge signal={signal} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Paper variant="outlined" sx={{ mb: 1.5, borderRadius: 1.5 }}>
|
||||
<Box sx={{ p: 1.5, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Layers size={14} color="#64748b" />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, fontSize: '0.8rem' }}>
|
||||
Matchbarkeits-Gate
|
||||
</Typography>
|
||||
<Box sx={{ ml: 'auto' }}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={GATE_STATUS_LABELS[gate.status]}
|
||||
sx={{ bgcolor: bg, color: fg, border: 'none', fontSize: '0.7rem', fontWeight: 600 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Divider />
|
||||
<Box sx={{ p: 1.5 }}>
|
||||
{gate.checks.map((check, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
{check.passed
|
||||
? <CheckCircle2 size={13} color="#15803d" />
|
||||
: <XCircle size={13} color="#dc2626" />
|
||||
}
|
||||
<Typography variant="caption">
|
||||
{check.label}
|
||||
{check.value && (
|
||||
<Box component="span" sx={{ color: 'text.secondary', ml: 0.5 }}>
|
||||
{check.value}
|
||||
</Box>
|
||||
)}
|
||||
{check.note && (
|
||||
<Box component="span" sx={{ color: '#dc2626', ml: 0.5, fontStyle: 'italic' }}>
|
||||
– {check.note}
|
||||
</Box>
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
{gate.reason && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: 'text.secondary', mt: 1, display: 'block' }}
|
||||
>
|
||||
{gate.reason}
|
||||
</Typography>
|
||||
)}
|
||||
{gate.nextAction && gate.status !== GateStatus.PASSED && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, mt: 0.5 }}>
|
||||
<ChevronRight size={12} color="#1d4ed8" />
|
||||
<Typography variant="caption" sx={{ color: '#1d4ed8', display: 'block' }}>
|
||||
{gate.nextAction}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Paper variant="outlined" sx={{ mb: 1.5, borderRadius: 1.5 }}>
|
||||
<Box sx={{ p: 1.5, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<ClipboardCheck size={14} color="#64748b" />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, fontSize: '0.8rem' }}>
|
||||
Review-Gate
|
||||
</Typography>
|
||||
<Box sx={{ ml: 'auto' }}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={GATE_STATUS_LABELS[gate.status]}
|
||||
sx={{ bgcolor: bg, color: fg, border: 'none', fontSize: '0.7rem', fontWeight: 600 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Divider />
|
||||
<Box sx={{ p: 1.5 }}>
|
||||
{gate.checks.map((check, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
{check.passed
|
||||
? <CheckCircle2 size={13} color="#15803d" />
|
||||
: <XCircle size={13} color="#dc2626" />
|
||||
}
|
||||
<Typography variant="caption">
|
||||
{check.label}
|
||||
{check.value && (
|
||||
<Box component="span" sx={{ color: 'text.secondary', ml: 0.5 }}>
|
||||
{check.value}
|
||||
</Box>
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{reviewerNotes.length > 0 && (
|
||||
<Box sx={{ mt: 1, p: 1, bgcolor: 'rgba(30,58,95,0.04)', borderRadius: 1, border: '1px solid rgba(30,58,95,0.1)' }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e3a5f', display: 'block', mb: 0.5 }}>
|
||||
Reviewer-Informationen
|
||||
</Typography>
|
||||
{reviewerNotes.map((note, i) => (
|
||||
<Typography key={i} variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>
|
||||
{note.label}: {note.value}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{gate.reason && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: 'text.secondary', mt: 1, display: 'block' }}
|
||||
>
|
||||
{gate.reason}
|
||||
</Typography>
|
||||
)}
|
||||
{gate.nextAction && gate.status !== GateStatus.PASSED && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, mt: 0.5 }}>
|
||||
<ChevronRight size={12} color="#1d4ed8" />
|
||||
<Typography variant="caption" sx={{ color: '#1d4ed8', display: 'block' }}>
|
||||
{gate.nextAction}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Paper variant="outlined" sx={{ mb: 1.5, borderRadius: 1.5 }}>
|
||||
<Box sx={{ p: 1.5, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<ShieldCheck size={14} color="#64748b" />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, fontSize: '0.8rem' }}>
|
||||
Sensitivitäts-Gate
|
||||
</Typography>
|
||||
<Box sx={{ ml: 'auto' }}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={GATE_STATUS_LABELS[gate.status]}
|
||||
sx={{ bgcolor: bg, color: fg, border: 'none', fontSize: '0.7rem', fontWeight: 600 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Divider />
|
||||
<Box sx={{ p: 1.5 }}>
|
||||
{gate.checks.map((check, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
{check.passed
|
||||
? <CheckCircle2 size={13} color="#15803d" />
|
||||
: <XCircle size={13} color="#dc2626" />
|
||||
}
|
||||
<Typography variant="caption">
|
||||
{check.label}
|
||||
{check.value && (
|
||||
<Box component="span" sx={{ color: 'text.secondary', ml: 0.5 }}>
|
||||
{check.value}
|
||||
</Box>
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{gate.status === GateStatus.FAILED && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{confidentialCheck && (
|
||||
<Alert severity="error" sx={{ py: 0.5, fontSize: '0.75rem', '& .MuiAlert-message': { py: 0.5 } }}>
|
||||
Sensitivitätsstufe CONFIDENTIAL: Dieses Signal darf nicht im Demand Feed erscheinen.
|
||||
Mieter- oder Vertragsidentitäten sind schützenswert und nur intern zugänglich.
|
||||
</Alert>
|
||||
)}
|
||||
{restrictedCheck && (
|
||||
<Alert severity="error" sx={{ py: 0.5, fontSize: '0.75rem', '& .MuiAlert-message': { py: 0.5 } }}>
|
||||
Sensitivitätsstufe RESTRICTED: Zugriff auf dieses Signal ist rollenbasiert eingeschränkt.
|
||||
Nur autorisierte Nutzer mit entsprechender Berechtigung dürfen es einsehen.
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{gate.reason && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: 'text.secondary', mt: 1, display: 'block' }}
|
||||
>
|
||||
{gate.reason}
|
||||
</Typography>
|
||||
)}
|
||||
{gate.nextAction && gate.status !== GateStatus.PASSED && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, mt: 0.5 }}>
|
||||
<ChevronRight size={12} color="#1d4ed8" />
|
||||
<Typography variant="caption" sx={{ color: '#1d4ed8', display: 'block' }}>
|
||||
{gate.nextAction}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Stepper
|
||||
activeStep={currentStageIndex}
|
||||
alternativeLabel
|
||||
sx={{
|
||||
mb: 1,
|
||||
'& .MuiStepIcon-root': { fontSize: '1.6rem', color: '#cbd5e1' },
|
||||
'& .MuiStepIcon-root.Mui-active': { color: '#1e3a5f' },
|
||||
'& .MuiStepIcon-root.Mui-completed': { color: '#15803d' },
|
||||
'& .MuiStepLabel-label': { fontSize: '0.68rem', mt: 0.5, color: '#64748b' },
|
||||
'& .MuiStepLabel-label.Mui-active': { color: '#1e3a5f', fontWeight: 700 },
|
||||
'& .MuiStepLabel-label.Mui-completed': { color: '#15803d' },
|
||||
'& .MuiStepConnector-line': { borderTopWidth: 2, borderColor: '#e2e8f0' },
|
||||
'& .MuiStepConnector-root.Mui-completed .MuiStepConnector-line': { borderColor: '#15803d' },
|
||||
'& .MuiStepConnector-root.Mui-active .MuiStepConnector-line': { borderColor: '#1e3a5f' },
|
||||
}}
|
||||
>
|
||||
{PIPELINE_STAGE_ORDER.map((stage) => (
|
||||
<Step key={stage}>
|
||||
<StepLabel>{PIPELINE_STAGE_LABELS[stage]}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Box sx={{ height: '100%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ p: 3, borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, lineHeight: 1.3, mb: 1 }}>
|
||||
{signal.title}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, alignItems: 'center' }}>
|
||||
<FeedEligibilityBadge signal={signal} />
|
||||
{pipelineState?.feedDisclaimer && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: '#a16207',
|
||||
bgcolor: 'rgba(234,179,8,0.08)',
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
borderRadius: 1,
|
||||
border: '1px solid rgba(234,179,8,0.2)',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{pipelineState.feedDisclaimer}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Pipeline Stepper */}
|
||||
<Box sx={{ p: 3, borderBottom: '1px solid #e2e8f0', flexShrink: 0, bgcolor: '#fafafa' }}>
|
||||
{isLoadingPipeline && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
|
||||
<CircularProgress size={20} />
|
||||
</Box>
|
||||
)}
|
||||
{pipelineState && <SignalPipelineStepper pipelineState={pipelineState} />}
|
||||
</Box>
|
||||
|
||||
{/* Gate panels */}
|
||||
<Box sx={{ p: 3, flex: 1 }}>
|
||||
{isLoadingPipeline && !pipelineState && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{pipelineState && (
|
||||
<>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5 }}>
|
||||
Gate-Bewertungen
|
||||
</Typography>
|
||||
|
||||
<EvidenceGatePanel gate={pipelineState.gates.EVIDENCE_GATE} />
|
||||
<ConfidenceGatePanel gate={pipelineState.gates.CONFIDENCE_GATE} />
|
||||
<SensitivityGatePanel gate={pipelineState.gates.SENSITIVITY_GATE} />
|
||||
<ReviewGatePanel gate={pipelineState.gates.REVIEW_GATE} />
|
||||
<MatchabilityGatePanel gate={pipelineState.gates.MATCHABILITY_GATE} />
|
||||
|
||||
{/* Feed eligibility summary */}
|
||||
{pipelineState.overallEligible && pipelineState.publishedToFutureAvailability && (
|
||||
<Alert
|
||||
severity="success"
|
||||
sx={{ mb: 2, fontSize: '0.8rem' }}
|
||||
>
|
||||
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',
|
||||
})}</>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{pipelineState.overallEligible && !pipelineState.publishedToFutureAvailability && (
|
||||
<Alert severity="info" sx={{ mb: 2, fontSize: '0.8rem' }}>
|
||||
Signal ist feed-fähig – noch nicht publiziert.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Publish button */}
|
||||
{canPublish && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={isPublishing ? <CircularProgress size={14} color="inherit" /> : <Rocket size={14} />}
|
||||
disabled={isPublishing}
|
||||
onClick={() => publish(signal.id)}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontSize: '0.8rem',
|
||||
bgcolor: '#1e3a5f',
|
||||
'&:hover': { bgcolor: '#162d4a' },
|
||||
}}
|
||||
>
|
||||
In Future Availability publizieren
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5 }}>
|
||||
Audit Trail
|
||||
</Typography>
|
||||
{isLoadingAudit ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
|
||||
<CircularProgress size={16} />
|
||||
</Box>
|
||||
) : (
|
||||
<SignalToMatchAuditTrail entries={auditTrail} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
Keine Audit-Einträge vorhanden.
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{entries.map((entry, idx) => {
|
||||
const isLast = idx === entries.length - 1
|
||||
const dotColor = getStageColor(entry)
|
||||
|
||||
return (
|
||||
<Box key={entry.id} sx={{ display: 'flex', gap: 1.5, mb: 1.5 }}>
|
||||
{/* Timeline column */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', flexShrink: 0 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
bgcolor: dotColor,
|
||||
mt: 0.25,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{!isLast && (
|
||||
<Box
|
||||
sx={{
|
||||
width: 2,
|
||||
flex: 1,
|
||||
minHeight: 16,
|
||||
bgcolor: 'rgba(148,163,184,0.3)',
|
||||
mt: 0.5,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Content column */}
|
||||
<Box sx={{ flex: 1, pb: isLast ? 0 : 0.5 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', lineHeight: 1.4 }}>
|
||||
{entry.action}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', lineHeight: 1.4 }}>
|
||||
{entry.performedBy} · {formatTimestamp(entry.timestamp)}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: '#64748b', display: 'block', lineHeight: 1.4, mt: 0.25 }}
|
||||
>
|
||||
{entry.details}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user