feat: remove Administration workspace — keep only Verwaltung + Suche

- Delete all ops page components (ReviewQueue, AIMonitoring, Governance,
  SourceMonitoring, ActivityTimeline, SignalPipeline)
- Remove OPERATIONS workspace from AppShell config, nav order, path detection
- Remove all /ops/* routes from App.tsx
- Remove WorkspaceType.OPERATIONS from allowedWorkspaces in authService,
  sessionStore, permissions
- Keep MarketIntelligence page (already moved to /supply/market-intelligence)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-19 20:32:41 +02:00
parent d22e72f945
commit d15a13e485
378 changed files with 35441 additions and 42 deletions
@@ -0,0 +1,45 @@
import { Component, type ReactNode } from 'react'
import { Box, Button, Typography } from '@mui/material'
import { AlertTriangle } from 'lucide-react'
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error?: Error
}
export class AppErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
handleReset = () => {
this.setState({ hasError: false, error: undefined })
}
render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback
return (
<Box className="flex flex-col items-center justify-center min-h-64 gap-4 p-8 text-center">
<AlertTriangle size={40} className="text-red-500" />
<Typography variant="h6" color="error">Unerwarteter Fehler</Typography>
<Typography variant="body2" color="text.secondary" className="max-w-md">
{this.state.error?.message ?? 'Ein unbekannter Fehler ist aufgetreten.'}
</Typography>
<Button variant="outlined" onClick={this.handleReset}>Neu laden</Button>
</Box>
)
}
return this.props.children
}
}
@@ -0,0 +1,33 @@
import { Box, Card, CardContent, Skeleton } from '@mui/material'
interface CardSkeletonProps {
lines?: number
hasHeader?: boolean
hasActions?: boolean
}
export function CardSkeleton({ lines = 3, hasHeader = true, hasActions = false }: CardSkeletonProps) {
return (
<Card>
<CardContent>
{hasHeader && (
<Box sx={{ mb: 1.5 }}>
<Skeleton variant="text" width="55%" height={22} />
<Skeleton variant="text" width="35%" height={16} />
</Box>
)}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{Array.from({ length: lines }).map((_, i) => (
<Skeleton key={i} variant="text" width={i % 2 === 0 ? '100%' : '80%'} height={16} />
))}
</Box>
{hasActions && (
<Box sx={{ display: 'flex', gap: 1, mt: 2 }}>
<Skeleton variant="rectangular" width={80} height={32} sx={{ borderRadius: 1 }} />
<Skeleton variant="rectangular" width={80} height={32} sx={{ borderRadius: 1 }} />
</Box>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,52 @@
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from '@mui/material'
import { AlertTriangle } from 'lucide-react'
interface Props {
open: boolean
title: string
message: string
confirmLabel?: string
cancelLabel?: string
destructive?: boolean
onConfirm: () => void
onCancel: () => void
}
export function ConfirmDialog({
open,
title,
message,
confirmLabel = 'Bestätigen',
cancelLabel = 'Abbrechen',
destructive = false,
onConfirm,
onCancel,
}: Props) {
return (
<Dialog open={open} onClose={onCancel} maxWidth="xs" fullWidth>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{destructive && <AlertTriangle size={18} color="#c0392b" />}
{title}
</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary">
{message}
</Typography>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button variant="outlined" size="small" onClick={onCancel}>
{cancelLabel}
</Button>
<Button
variant="contained"
size="small"
color={destructive ? 'error' : 'primary'}
onClick={onConfirm}
autoFocus
>
{confirmLabel}
</Button>
</DialogActions>
</Dialog>
)
}
@@ -0,0 +1,158 @@
import { useState } from 'react'
import { Alert, Box, Button, Chip, Collapse, IconButton, Typography } from '@mui/material'
import { AlertTriangle, ChevronDown, ChevronUp, Target } from 'lucide-react'
import type { ReactNode } from 'react'
export interface DecisionMetric {
label: string
value: string | number
severity?: 'neutral' | 'positive' | 'warning' | 'critical'
}
export interface DecisionAction {
label: string
primary?: boolean
onClick: () => void
}
interface Props {
/** The core question this screen answers */
decision: string
/** One-line explanation of why this decision matters */
context?: string
/** Key data points relevant to the decision */
metrics?: DecisionMetric[]
/** Missing data that could affect the decision */
missing?: string[]
/** Active risks the user should be aware of */
risks?: string[]
/** Available actions — first primary action is highlighted */
actions?: DecisionAction[]
/** Custom content after the standard rows */
children?: ReactNode
}
const SEVERITY_COLOR: Record<NonNullable<DecisionMetric['severity']>, string> = {
neutral: '#f1f5f9',
positive: '#f0fdf4',
warning: '#fef9c3',
critical: '#fef2f2',
}
const SEVERITY_TEXT: Record<NonNullable<DecisionMetric['severity']>, string> = {
neutral: '#475569',
positive: '#1a7a4a',
warning: '#92400e',
critical: '#991b1b',
}
export function DecisionContextPanel({
decision,
context,
metrics = [],
missing = [],
risks = [],
actions = [],
children,
}: Props) {
const [expanded, setExpanded] = useState(false)
const hasDetails = missing.length > 0 || risks.length > 0 || !!children
return (
<Box
sx={{
bgcolor: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
borderLeft: '3px solid #1e3a5f',
px: 2.5,
py: 1.25,
flexShrink: 0,
}}
>
{/* Main row */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2, flexWrap: 'wrap' }}>
{/* Decision question */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, flex: 1, minWidth: 200 }}>
<Target size={15} color="#1e3a5f" style={{ marginTop: 2, flexShrink: 0 }} />
<Box>
<Typography variant="body2" sx={{ fontWeight: 700, fontSize: '0.8125rem', color: '#1e293b', lineHeight: 1.3 }}>
{decision}
</Typography>
{context && (
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', mt: 0.25 }}>
{context}
</Typography>
)}
</Box>
</Box>
{/* Metric chips */}
{metrics.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', alignItems: 'center' }}>
{metrics.map((m, i) => {
const sev = m.severity ?? 'neutral'
return (
<Chip
key={i}
label={`${m.value} ${m.label}`}
size="small"
sx={{
bgcolor: SEVERITY_COLOR[sev],
color: SEVERITY_TEXT[sev],
fontWeight: sev !== 'neutral' ? 700 : 400,
fontSize: '0.7rem',
height: 22,
}}
/>
)
})}
</Box>
)}
{/* Actions + expand toggle */}
<Box sx={{ display: 'flex', gap: 0.75, alignItems: 'center', flexShrink: 0 }}>
{actions.map((a, i) => (
<Button
key={i}
size="small"
variant={a.primary ? 'contained' : 'outlined'}
onClick={a.onClick}
sx={a.primary
? { bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none', fontSize: '0.75rem', py: 0.375, px: 1.25 }
: { textTransform: 'none', fontSize: '0.75rem', py: 0.375, px: 1.25 }
}
>
{a.label}
</Button>
))}
{hasDetails && (
<IconButton
size="small"
onClick={() => setExpanded(v => !v)}
sx={{ color: '#64748b', width: 24, height: 24 }}
>
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</IconButton>
)}
</Box>
</Box>
{/* Expandable details */}
<Collapse in={expanded}>
<Box sx={{ mt: 1.25, display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{risks.length > 0 && (
<Alert severity="warning" icon={<AlertTriangle size={14} />} sx={{ py: 0.25, '& .MuiAlert-message': { fontSize: '0.75rem' } }}>
<strong>Risiken:</strong>{' '}{risks.join(' · ')}
</Alert>
)}
{missing.length > 0 && (
<Alert severity="info" sx={{ py: 0.25, '& .MuiAlert-message': { fontSize: '0.75rem' } }}>
<strong>Fehlende Daten:</strong>{' '}{missing.join(' · ')}
</Alert>
)}
{children}
</Box>
</Collapse>
</Box>
)
}
@@ -0,0 +1,29 @@
import { Box, Button, Typography } from '@mui/material'
import type { ReactNode } from 'react'
interface EmptyStateProps {
icon?: ReactNode
title: string
description?: string
action?: {
label: string
onClick: () => void
}
}
export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
return (
<Box className="flex flex-col items-center justify-center gap-3 py-16 px-8 text-center">
{icon && <Box className="text-slate-400 mb-2">{icon}</Box>}
<Typography variant="h6" color="text.primary" sx={{ fontWeight: 500 }}>{title}</Typography>
{description && (
<Typography variant="body2" color="text.secondary" className="max-w-sm">{description}</Typography>
)}
{action && (
<Button variant="outlined" size="small" onClick={action.onClick} className="mt-2">
{action.label}
</Button>
)}
</Box>
)
}
@@ -0,0 +1,19 @@
import { Box, Button, Typography } from '@mui/material'
import { AlertCircle } from 'lucide-react'
interface ErrorStateProps {
message?: string
onRetry?: () => void
}
export function ErrorState({ message = 'Daten konnten nicht geladen werden.', onRetry }: ErrorStateProps) {
return (
<Box className="flex flex-col items-center justify-center gap-3 py-16 px-8 text-center">
<AlertCircle size={36} className="text-red-400" />
<Typography variant="body1" color="text.secondary">{message}</Typography>
{onRetry && (
<Button variant="outlined" size="small" onClick={onRetry}>Erneut versuchen</Button>
)}
</Box>
)
}
@@ -0,0 +1,21 @@
import { Box, Skeleton } from '@mui/material'
interface LoadingPageProps {
rows?: number
}
export function LoadingPage({ rows = 5 }: LoadingPageProps) {
return (
<Box className="flex flex-col gap-4 p-6 w-full">
<Skeleton variant="rectangular" height={48} className="rounded" />
<Box className="flex gap-4">
{[1, 2, 3, 4].map(i => (
<Skeleton key={i} variant="rectangular" height={80} className="flex-1 rounded" />
))}
</Box>
{Array.from({ length: rows }).map((_, i) => (
<Skeleton key={i} variant="rectangular" height={64} className="rounded" />
))}
</Box>
)
}
@@ -0,0 +1,19 @@
import { Box } from '@mui/material'
import type { ReactNode } from 'react'
interface PageContainerProps {
children: ReactNode
maxWidth?: string | number
className?: string
}
export function PageContainer({ children, maxWidth = 1440, className = '' }: PageContainerProps) {
return (
<Box
className={`w-full mx-auto px-6 py-6 ${className}`}
sx={{ maxWidth }}
>
{children}
</Box>
)
}
@@ -0,0 +1,22 @@
import { Box, Skeleton } from '@mui/material'
interface PanelLoadingStateProps {
rows?: number
height?: number
}
export function PanelLoadingState({ rows = 4, height = 16 }: PanelLoadingStateProps) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, p: 1 }}>
{Array.from({ length: rows }).map((_, i) => (
<Skeleton
key={i}
variant="rectangular"
width={i % 3 === 2 ? '60%' : i % 3 === 1 ? '85%' : '100%'}
height={height}
sx={{ borderRadius: 0.5 }}
/>
))}
</Box>
)
}
@@ -0,0 +1,37 @@
import { Box, Typography } from '@mui/material'
import { ShieldOff } from 'lucide-react'
import type { SxProps, Theme } from '@mui/material'
interface RestrictedStateProps {
message?: string
reason?: string
sx?: SxProps<Theme>
}
export function RestrictedState({ message, reason, sx }: RestrictedStateProps) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 1,
py: 4,
px: 2,
textAlign: 'center',
...sx,
}}
>
<ShieldOff size={28} color="#94a3b8" />
<Typography sx={{ fontWeight: 600, fontSize: '0.875rem', color: 'text.secondary' }}>
{message ?? 'Zugriff eingeschränkt'}
</Typography>
{reason && (
<Typography sx={{ fontSize: '0.75rem', color: 'text.disabled', maxWidth: 280 }}>
{reason}
</Typography>
)}
</Box>
)
}
@@ -0,0 +1,29 @@
import { Box, Divider, Typography } from '@mui/material'
import type { ReactNode } from 'react'
interface SectionContainerProps {
title?: string
subtitle?: string
action?: ReactNode
children: ReactNode
className?: string
divider?: boolean
}
export function SectionContainer({ title, subtitle, action, children, className = '', divider = false }: SectionContainerProps) {
return (
<Box className={`flex flex-col gap-3 ${className}`}>
{(title || action) && (
<Box className="flex items-center justify-between gap-2">
<Box>
{title && <Typography variant="subtitle1" sx={{ fontWeight: 600 }} color="text.primary">{title}</Typography>}
{subtitle && <Typography variant="caption" color="text.secondary">{subtitle}</Typography>}
</Box>
{action}
</Box>
)}
{divider && <Divider />}
{children}
</Box>
)
}
@@ -0,0 +1,33 @@
import { Alert, Snackbar, Stack } from '@mui/material'
import { useToastStore } from '../../stores/toastStore'
export function ToastProvider() {
const { toasts, dismissToast } = useToastStore()
return (
<Stack
spacing={1}
sx={{ position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)', zIndex: 9999, alignItems: 'center' }}
>
{toasts.map((toast) => (
<Snackbar
key={toast.id}
open
autoHideDuration={toast.duration ?? 4000}
onClose={() => dismissToast(toast.id)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
sx={{ position: 'relative', transform: 'none', left: 'auto', bottom: 'auto' }}
>
<Alert
onClose={() => dismissToast(toast.id)}
severity={toast.severity}
variant="filled"
sx={{ minWidth: 320, boxShadow: 3 }}
>
{toast.message}
</Alert>
</Snackbar>
))}
</Stack>
)
}
@@ -0,0 +1,39 @@
import { Box, Button, Typography } from '@mui/material'
import { Lock } from 'lucide-react'
interface UnauthorizedStateProps {
message?: string
onLogin?: () => void
}
export function UnauthorizedState({ message, onLogin }: UnauthorizedStateProps) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 1.5,
py: 6,
px: 3,
textAlign: 'center',
}}
>
<Lock size={36} color="#94a3b8" />
<Typography sx={{ fontWeight: 600, fontSize: '1rem', color: 'text.primary' }}>
Nicht berechtigt
</Typography>
{message && (
<Typography sx={{ fontSize: '0.875rem', color: 'text.secondary', maxWidth: 360 }}>
{message}
</Typography>
)}
{onLogin && (
<Button variant="outlined" size="small" onClick={onLogin}>
Anmelden
</Button>
)}
</Box>
)
}
@@ -0,0 +1,14 @@
export { AppErrorBoundary } from './AppErrorBoundary'
export { LoadingPage } from './LoadingPage'
export { EmptyState } from './EmptyState'
export { ErrorState } from './ErrorState'
export { PageContainer } from './PageContainer'
export { SectionContainer } from './SectionContainer'
export { CardSkeleton } from './CardSkeleton'
export { PanelLoadingState } from './PanelLoadingState'
export { UnauthorizedState } from './UnauthorizedState'
export { RestrictedState } from './RestrictedState'
export { ToastProvider } from './ToastProvider'
export { ConfirmDialog } from './ConfirmDialog'
export { DecisionContextPanel } from './DecisionContextPanel'
export type { DecisionMetric, DecisionAction } from './DecisionContextPanel'