feat: F019 AI monitoring layer — output transparency & governance workspace
Full 2-panel workspace at /ops/ai-monitoring: metrics strip (total, failed, pending, approval rate, top prompt version, active model), filterable output table by type/status/error, and detail panel with output preview, prompt versioning, error details, and role-aware review actions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { Chip, Tooltip } from '@mui/material'
|
||||
import type { AIOutputError } from '../../domain/aiOutput'
|
||||
|
||||
const ERROR_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
SCHEMA_VALIDATION: { label: 'Schema', color: '#ea580c' },
|
||||
PROVIDER_TIMEOUT: { label: 'Timeout', color: '#c0392b' },
|
||||
INVALID_JSON: { label: 'JSON', color: '#c0392b' },
|
||||
EMPTY_RESPONSE: { label: 'Leer', color: '#d97706' },
|
||||
RATE_LIMIT: { label: 'Rate Limit', color: '#7c3aed' },
|
||||
}
|
||||
|
||||
export function AIErrorBadge({ error }: { error: AIOutputError }) {
|
||||
const { label, color } = ERROR_CONFIG[error.type] ?? { label: error.type, color: '#c0392b' }
|
||||
return (
|
||||
<Tooltip title={error.message} arrow>
|
||||
<Chip
|
||||
size="small"
|
||||
label={label}
|
||||
sx={{ bgcolor: `${color}18`, color, fontWeight: 700, fontSize: '0.65rem', cursor: 'default' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { Bot, Filter, MousePointer } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
context: 'no-outputs' | 'filtered-empty' | 'no-selection'
|
||||
}
|
||||
|
||||
const CONFIG = {
|
||||
'no-outputs': {
|
||||
icon: Bot,
|
||||
color: '#94a3b8',
|
||||
title: 'Keine AI-Outputs',
|
||||
desc: 'Es wurden noch keine AI-Outputs generiert.',
|
||||
},
|
||||
'filtered-empty': {
|
||||
icon: Filter,
|
||||
color: '#94a3b8',
|
||||
title: 'Keine Ergebnisse',
|
||||
desc: 'Kein AI-Output entspricht den aktiven Filtern.',
|
||||
},
|
||||
'no-selection': {
|
||||
icon: MousePointer,
|
||||
color: '#94a3b8',
|
||||
title: 'Output auswählen',
|
||||
desc: 'Klicken Sie auf eine Zeile, um Details und Aktionen anzuzeigen.',
|
||||
},
|
||||
}
|
||||
|
||||
export function AIMonitoringEmptyState({ context }: Props) {
|
||||
const { icon: Icon, color, title, desc } = CONFIG[context]
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', minHeight: 200, p: 4, textAlign: 'center' }}>
|
||||
<Icon size={36} color={color} style={{ marginBottom: 12, opacity: 0.6 }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5, color: '#1e293b' }}>{title}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 260 }}>{desc}</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import type { AIOutput } from '../../domain/aiOutput'
|
||||
|
||||
interface Props {
|
||||
outputs: AIOutput[]
|
||||
}
|
||||
|
||||
function MetricCell({ label, value, color }: { label: string; value: string | number; color?: string }) {
|
||||
return (
|
||||
<Box sx={{ px: 2, py: 1.25, borderRight: '1px solid #e2e8f0', '&:last-child': { borderRight: 'none' }, minWidth: 0, flex: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', whiteSpace: 'nowrap', mb: 0.25 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ fontWeight: 700, fontSize: '0.9375rem', color: color ?? '#1e293b', lineHeight: 1.2 }}
|
||||
>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function mostCommon(arr: string[]): string {
|
||||
if (!arr.length) return '–'
|
||||
const freq = arr.reduce<Record<string, number>>((acc, v) => ({ ...acc, [v]: (acc[v] ?? 0) + 1 }), {})
|
||||
return Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0]
|
||||
}
|
||||
|
||||
export function AIMonitoringMetrics({ outputs }: Props) {
|
||||
const total = outputs.length
|
||||
const failed = outputs.filter(o => !!o.error).length
|
||||
const needsReview = outputs.filter(o => o.reviewStatus === 'UNREVIEWED' || o.reviewStatus === 'FLAGGED').length
|
||||
const approved = outputs.filter(o => o.reviewStatus === 'APPROVED').length
|
||||
const approvalRate = total > 0 ? Math.round((approved / total) * 100) : 0
|
||||
const topPrompt = mostCommon(outputs.map(o => o.promptVersion))
|
||||
const latestModel = outputs.length > 0
|
||||
? outputs.sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0].model
|
||||
: '–'
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', bgcolor: 'white', borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
|
||||
<MetricCell label="AI-Outputs total" value={total} />
|
||||
<MetricCell label="Fehlgeschlagen" value={failed} color={failed > 0 ? '#c0392b' : undefined} />
|
||||
<MetricCell label="Prüfung ausstehend" value={needsReview} color={needsReview > 0 ? '#d97706' : undefined} />
|
||||
<MetricCell label="Genehmigungsrate" value={`${approvalRate}%`} color={approvalRate >= 70 ? '#1a7a4a' : '#d97706'} />
|
||||
<MetricCell label="Häufigste Version" value={topPrompt} />
|
||||
<MetricCell label="Aktives Modell" value={latestModel.replace('claude-3-5-sonnet-20241022', 'sonnet-3.5').replace('claude-3-haiku-20240307', 'haiku-3').replace('claude-3-opus-20240229', 'opus-3')} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { Alert, Box, Divider, IconButton, Typography } from '@mui/material'
|
||||
import { X } from 'lucide-react'
|
||||
import { AIOutputStatusBadge } from './AIOutputStatusBadge'
|
||||
import { PromptVersionBadge } from './PromptVersionBadge'
|
||||
import { AIErrorBadge } from './AIErrorBadge'
|
||||
import { AIReviewActionToolbar } from './AIReviewActionToolbar'
|
||||
import type { AIOutput, AIOutputType } from '../../domain/aiOutput'
|
||||
import type { ReviewStatus } from '../../domain/enums'
|
||||
|
||||
const TYPE_LABELS: Record<AIOutputType, string> = {
|
||||
NEED_PARSE: 'Bedarf-Parsing',
|
||||
FOLLOW_UP_QUESTIONS: 'Rückfragen',
|
||||
MATCH_EXPLANATION: 'Match-Begründung',
|
||||
COMPARE_SUMMARY: 'Vergleich',
|
||||
DECISION_BRIEF: 'Entscheidungs-Brief',
|
||||
DATA_QUALITY_SUMMARY: 'Datenqualität',
|
||||
}
|
||||
|
||||
const ERROR_TYPE_LABELS: Record<string, string> = {
|
||||
SCHEMA_VALIDATION: 'Schema-Validierungsfehler',
|
||||
PROVIDER_TIMEOUT: 'Provider-Timeout',
|
||||
INVALID_JSON: 'Ungültiges JSON',
|
||||
EMPTY_RESPONSE: 'Leere Antwort',
|
||||
RATE_LIMIT: 'Rate-Limit erreicht',
|
||||
}
|
||||
|
||||
const MODEL_LABELS: Record<string, string> = {
|
||||
'claude-3-5-sonnet-20241022': 'Claude 3.5 Sonnet',
|
||||
'claude-3-haiku-20240307': 'Claude 3 Haiku',
|
||||
'claude-3-opus-20240229': 'Claude 3 Opus',
|
||||
}
|
||||
|
||||
const ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||
NEED: 'Gesuch', MATCH: 'Match', PROPERTY: 'Objekt', SIGNAL: 'Signal',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
output: AIOutput
|
||||
onClose: () => void
|
||||
onUpdateStatus: (status: ReviewStatus) => void
|
||||
isSubmitting?: boolean
|
||||
}
|
||||
|
||||
function MetaRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 0.625, alignItems: 'flex-start' }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 116, flexShrink: 0, pt: 0.125 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>{children}</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function AIOutputDetailPanel({ output, onClose, onUpdateStatus, isSubmitting }: Props) {
|
||||
const handleCopyJson = () => {
|
||||
navigator.clipboard.writeText(output.outputPreview).catch(() => {})
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ px: 2, py: 1.5, borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 0.5, alignItems: 'center' }}>
|
||||
<AIOutputStatusBadge status={output.reviewStatus} />
|
||||
{output.error && <AIErrorBadge error={output.error} />}
|
||||
</Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.875rem' }}>
|
||||
{TYPE_LABELS[output.type] ?? output.type}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton size="small" onClick={onClose} sx={{ flexShrink: 0, mt: -0.25 }}>
|
||||
<X size={16} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Scrollable body */}
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 2, py: 1.5 }}>
|
||||
{/* Metadata */}
|
||||
<MetaRow label="Output-ID">
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569' }}>
|
||||
{output.id}
|
||||
</Typography>
|
||||
</MetaRow>
|
||||
<MetaRow label="Erstellt">
|
||||
<Typography variant="caption" sx={{ color: '#334155' }}>
|
||||
{new Date(output.createdAt).toLocaleString('de-CH', { dateStyle: 'medium', timeStyle: 'short' })}
|
||||
</Typography>
|
||||
</MetaRow>
|
||||
<MetaRow label="Modell">
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#334155' }}>
|
||||
{MODEL_LABELS[output.model] ?? output.model}
|
||||
</Typography>
|
||||
</MetaRow>
|
||||
<MetaRow label="Provider">
|
||||
<Typography variant="caption" sx={{ color: '#334155', textTransform: 'capitalize' }}>
|
||||
{output.provider}
|
||||
</Typography>
|
||||
</MetaRow>
|
||||
<MetaRow label="Prompt-Version">
|
||||
<PromptVersionBadge promptVersion={output.promptVersion} schemaVersion={output.schemaVersion} />
|
||||
</MetaRow>
|
||||
<MetaRow label="Input-Hash">
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#94a3b8' }}>
|
||||
{output.inputHash}
|
||||
</Typography>
|
||||
</MetaRow>
|
||||
<MetaRow label="Bezug">
|
||||
<Typography variant="caption" sx={{ color: '#334155' }}>
|
||||
{ENTITY_TYPE_LABELS[output.relatedEntityType] ?? output.relatedEntityType}{' '}
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#94a3b8' }}>
|
||||
{output.relatedEntityId}
|
||||
</span>
|
||||
</Typography>
|
||||
</MetaRow>
|
||||
{output.latencyMs != null && (
|
||||
<MetaRow label="Latenz">
|
||||
<Typography variant="caption" sx={{ color: output.latencyMs > 5000 ? '#c0392b' : '#334155', fontWeight: output.latencyMs > 5000 ? 700 : 400 }}>
|
||||
{(output.latencyMs / 1000).toFixed(2)}s
|
||||
</Typography>
|
||||
</MetaRow>
|
||||
)}
|
||||
{output.costEstimate != null && (
|
||||
<MetaRow label="Kostenschätzung">
|
||||
<Typography variant="caption" sx={{ color: '#334155' }}>
|
||||
${output.costEstimate.toFixed(4)}
|
||||
</Typography>
|
||||
</MetaRow>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
|
||||
{/* Error details */}
|
||||
{output.error && (
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{ '& .MuiAlert-message': { fontSize: '0.8rem' }, mb: 1 }}
|
||||
>
|
||||
<strong>{ERROR_TYPE_LABELS[output.error.type] ?? output.error.type}</strong>
|
||||
<br />
|
||||
{output.error.message}
|
||||
{output.error.recoverable && (
|
||||
<Typography variant="caption" sx={{ display: 'block', mt: 0.5, color: '#92400e' }}>
|
||||
Wiederholbar — kann erneut ausgelöst werden.
|
||||
</Typography>
|
||||
)}
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Output preview */}
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: '#64748b', display: 'block', mb: 0.5 }}>
|
||||
Output-Vorschau
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.25,
|
||||
bgcolor: '#f8fafc',
|
||||
borderRadius: 1,
|
||||
border: '1px solid #e2e8f0',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.75rem',
|
||||
color: '#334155',
|
||||
lineHeight: 1.6,
|
||||
overflowX: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{output.outputPreview || '(kein Output)'}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 1.5 }} />
|
||||
|
||||
{/* Actions */}
|
||||
<AIReviewActionToolbar
|
||||
output={output}
|
||||
onUpdateStatus={onUpdateStatus}
|
||||
onCopyJson={handleCopyJson}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Chip } from '@mui/material'
|
||||
import type { ReviewStatus } from '../../domain/enums'
|
||||
|
||||
const CONFIG: Record<ReviewStatus, { label: string; color: string }> = {
|
||||
UNREVIEWED: { label: 'Ungeprüft', color: '#94a3b8' },
|
||||
IN_REVIEW: { label: 'In Prüfung', color: '#d97706' },
|
||||
APPROVED: { label: 'Genehmigt', color: '#1a7a4a' },
|
||||
REJECTED: { label: 'Abgelehnt', color: '#c0392b' },
|
||||
FLAGGED: { label: 'Markiert', color: '#ea580c' },
|
||||
}
|
||||
|
||||
export function AIOutputStatusBadge({ status }: { status: ReviewStatus }) {
|
||||
const { label, color } = CONFIG[status] ?? { label: status, color: '#64748b' }
|
||||
return (
|
||||
<Chip
|
||||
size="small"
|
||||
label={label}
|
||||
sx={{ bgcolor: `${color}18`, color, fontWeight: 600, fontSize: '0.7rem' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
Box,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { AIOutputStatusBadge } from './AIOutputStatusBadge'
|
||||
import { PromptVersionBadge } from './PromptVersionBadge'
|
||||
import { AIErrorBadge } from './AIErrorBadge'
|
||||
import { AIMonitoringEmptyState } from './AIMonitoringEmptyState'
|
||||
import type { AIOutput, AIOutputType } from '../../domain/aiOutput'
|
||||
|
||||
const TYPE_LABELS: Record<AIOutputType, string> = {
|
||||
NEED_PARSE: 'Bedarf-Parsing',
|
||||
FOLLOW_UP_QUESTIONS: 'Rückfragen',
|
||||
MATCH_EXPLANATION: 'Match-Begründung',
|
||||
COMPARE_SUMMARY: 'Vergleich',
|
||||
DECISION_BRIEF: 'Entscheidungs-Brief',
|
||||
DATA_QUALITY_SUMMARY: 'Datenqualität',
|
||||
}
|
||||
|
||||
const TYPE_COLORS: Record<AIOutputType, string> = {
|
||||
NEED_PARSE: '#1e3a5f',
|
||||
FOLLOW_UP_QUESTIONS: '#0891b2',
|
||||
MATCH_EXPLANATION: '#4f46e5',
|
||||
COMPARE_SUMMARY: '#1a7a4a',
|
||||
DECISION_BRIEF: '#7c3aed',
|
||||
DATA_QUALITY_SUMMARY: '#d97706',
|
||||
}
|
||||
|
||||
const MODEL_SHORT: Record<string, string> = {
|
||||
'claude-3-5-sonnet-20241022': 'Sonnet 3.5',
|
||||
'claude-3-haiku-20240307': 'Haiku 3',
|
||||
'claude-3-opus-20240229': 'Opus 3',
|
||||
}
|
||||
|
||||
function shortTime(iso: string) {
|
||||
return new Date(iso).toLocaleString('de-CH', { dateStyle: 'short', timeStyle: 'short' })
|
||||
}
|
||||
|
||||
interface Props {
|
||||
outputs: AIOutput[]
|
||||
selectedId: string | null
|
||||
onSelect: (output: AIOutput) => void
|
||||
isEmpty: boolean
|
||||
}
|
||||
|
||||
export function AIOutputTable({ outputs, selectedId, onSelect, isEmpty }: Props) {
|
||||
if (isEmpty && outputs.length === 0) {
|
||||
return <AIMonitoringEmptyState context="no-outputs" />
|
||||
}
|
||||
if (outputs.length === 0) {
|
||||
return <AIMonitoringEmptyState context="filtered-empty" />
|
||||
}
|
||||
|
||||
return (
|
||||
<Table size="small" stickyHeader>
|
||||
<TableHead>
|
||||
<TableRow sx={{ '& th': { bgcolor: '#f8fafc', fontSize: '0.7rem', fontWeight: 700, color: '#64748b', py: 0.75, textTransform: 'uppercase', letterSpacing: 0.4 } }}>
|
||||
<TableCell sx={{ minWidth: 110 }}>Zeitpunkt</TableCell>
|
||||
<TableCell sx={{ minWidth: 130 }}>Typ</TableCell>
|
||||
<TableCell sx={{ minWidth: 100 }}>Modell</TableCell>
|
||||
<TableCell sx={{ minWidth: 140 }}>Version</TableCell>
|
||||
<TableCell sx={{ minWidth: 95 }}>Status</TableCell>
|
||||
<TableCell sx={{ minWidth: 65 }}>Latenz</TableCell>
|
||||
<TableCell sx={{ minWidth: 80 }}>Fehler</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{outputs.map(output => {
|
||||
const isSelected = selectedId === output.id
|
||||
const color = TYPE_COLORS[output.type] ?? '#64748b'
|
||||
return (
|
||||
<TableRow
|
||||
key={output.id}
|
||||
hover
|
||||
onClick={() => onSelect(output)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
bgcolor: isSelected ? '#eff6ff' : undefined,
|
||||
borderLeft: isSelected ? '3px solid #1e3a5f' : '3px solid transparent',
|
||||
'&:hover': { bgcolor: isSelected ? '#eff6ff' : '#f8fafc' },
|
||||
'& td': { py: 0.75, borderBottom: '1px solid #f1f5f9' },
|
||||
}}
|
||||
>
|
||||
<TableCell>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
|
||||
{shortTime(output.createdAt)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
px: 0.75,
|
||||
py: 0.2,
|
||||
borderRadius: 1,
|
||||
bgcolor: `${color}12`,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color, fontWeight: 700, fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
|
||||
{TYPE_LABELS[output.type] ?? output.type}
|
||||
</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" sx={{ fontWeight: 500, fontSize: '0.7rem', color: '#334155' }}>
|
||||
{MODEL_SHORT[output.model] ?? output.model}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<PromptVersionBadge promptVersion={output.promptVersion} schemaVersion={output.schemaVersion} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AIOutputStatusBadge status={output.reviewStatus} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" sx={{ fontSize: '0.7rem', color: output.latencyMs && output.latencyMs > 5000 ? '#c0392b' : '#64748b' }}>
|
||||
{output.latencyMs != null ? `${(output.latencyMs / 1000).toFixed(1)}s` : '–'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{output.error ? <AIErrorBadge error={output.error} /> : (
|
||||
<Typography variant="caption" color="text.disabled" sx={{ fontSize: '0.7rem' }}>–</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Box, Button, Tooltip } from '@mui/material'
|
||||
import { CheckCircle, XCircle, Send, Copy } from 'lucide-react'
|
||||
import type { AIOutput } from '../../domain/aiOutput'
|
||||
import type { ReviewStatus } from '../../domain/enums'
|
||||
|
||||
interface Props {
|
||||
output: AIOutput
|
||||
onUpdateStatus: (status: ReviewStatus) => void
|
||||
onCopyJson: () => void
|
||||
isSubmitting?: boolean
|
||||
}
|
||||
|
||||
export function AIReviewActionToolbar({ output, onUpdateStatus, onCopyJson, isSubmitting }: Props) {
|
||||
const { reviewStatus } = output
|
||||
|
||||
const canSendToReview = reviewStatus === 'UNREVIEWED' || reviewStatus === 'FLAGGED'
|
||||
const canApprove = reviewStatus === 'IN_REVIEW' || reviewStatus === 'UNREVIEWED'
|
||||
const canReject = reviewStatus !== 'REJECTED'
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap' }}>
|
||||
{canSendToReview && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
disabled={isSubmitting}
|
||||
startIcon={<Send size={13} />}
|
||||
onClick={() => onUpdateStatus('IN_REVIEW')}
|
||||
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#d97706', borderColor: '#d97706', '&:hover': { bgcolor: '#fffbeb', borderColor: '#b45309' } }}
|
||||
>
|
||||
Zur Prüfung
|
||||
</Button>
|
||||
)}
|
||||
{canApprove && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
disabled={isSubmitting}
|
||||
startIcon={<CheckCircle size={13} />}
|
||||
onClick={() => onUpdateStatus('APPROVED')}
|
||||
sx={{ textTransform: 'none', fontSize: '0.75rem', bgcolor: '#1a7a4a', '&:hover': { bgcolor: '#155f3a' } }}
|
||||
>
|
||||
Genehmigen
|
||||
</Button>
|
||||
)}
|
||||
{canReject && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
disabled={isSubmitting}
|
||||
startIcon={<XCircle size={13} />}
|
||||
onClick={() => onUpdateStatus('REJECTED')}
|
||||
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#c0392b', borderColor: '#c0392b', '&:hover': { bgcolor: '#fef2f2', borderColor: '#a93226' } }}
|
||||
>
|
||||
Ablehnen
|
||||
</Button>
|
||||
)}
|
||||
<Tooltip title="Output-JSON kopieren">
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={onCopyJson}
|
||||
startIcon={<Copy size={13} />}
|
||||
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#64748b', borderColor: '#e2e8f0', '&:hover': { bgcolor: '#f8fafc' } }}
|
||||
>
|
||||
JSON
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Box, Tooltip, Typography } from '@mui/material'
|
||||
|
||||
interface Props {
|
||||
promptVersion: string
|
||||
schemaVersion?: string
|
||||
}
|
||||
|
||||
export function PromptVersionBadge({ promptVersion, schemaVersion }: Props) {
|
||||
const badge = (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
px: 0.75,
|
||||
py: 0.2,
|
||||
bgcolor: '#f1f5f9',
|
||||
borderRadius: 1,
|
||||
border: '1px solid #e2e8f0',
|
||||
cursor: schemaVersion ? 'default' : undefined,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', fontWeight: 600, lineHeight: 1.4 }}
|
||||
>
|
||||
{promptVersion}
|
||||
</Typography>
|
||||
{schemaVersion && (
|
||||
<>
|
||||
<Box sx={{ width: '1px', height: 10, bgcolor: '#cbd5e1', flexShrink: 0 }} />
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontFamily: 'monospace', fontSize: '0.6rem', color: '#94a3b8', lineHeight: 1.4 }}
|
||||
>
|
||||
{schemaVersion}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
|
||||
return schemaVersion ? (
|
||||
<Tooltip title={`Prompt: ${promptVersion} · Schema: ${schemaVersion}`}>{badge}</Tooltip>
|
||||
) : badge
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { AIOutputStatusBadge } from './AIOutputStatusBadge'
|
||||
export { PromptVersionBadge } from './PromptVersionBadge'
|
||||
export { AIErrorBadge } from './AIErrorBadge'
|
||||
export { AIMonitoringEmptyState } from './AIMonitoringEmptyState'
|
||||
export { AIMonitoringMetrics } from './AIMonitoringMetrics'
|
||||
export { AIOutputTable } from './AIOutputTable'
|
||||
export { AIReviewActionToolbar } from './AIReviewActionToolbar'
|
||||
export { AIOutputDetailPanel } from './AIOutputDetailPanel'
|
||||
Reference in New Issue
Block a user