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:
@@ -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'
|
||||
Reference in New Issue
Block a user