feat: F001 app shell & global layout system
Add PageHeader, UserMenu, NotificationButton, OrganizationContextBadge, RightContextPanel, CompareTray, and ActivityTimeline; wire all into AppShell. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ const Shortlists = lazy(() => import('./pages/demand/Shortlists'))
|
|||||||
const ReviewQueue = lazy(() => import('./pages/ops/ReviewQueue'))
|
const ReviewQueue = lazy(() => import('./pages/ops/ReviewQueue'))
|
||||||
const AIMonitoring = lazy(() => import('./pages/ops/AIMonitoring'))
|
const AIMonitoring = lazy(() => import('./pages/ops/AIMonitoring'))
|
||||||
const Governance = lazy(() => import('./pages/ops/Governance'))
|
const Governance = lazy(() => import('./pages/ops/Governance'))
|
||||||
|
const ActivityTimeline = lazy(() => import('./pages/ops/ActivityTimeline'))
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -43,6 +44,7 @@ function App() {
|
|||||||
<Route path="/ops/review-queue" element={<ReviewQueue />} />
|
<Route path="/ops/review-queue" element={<ReviewQueue />} />
|
||||||
<Route path="/ops/ai-monitoring" element={<AIMonitoring />} />
|
<Route path="/ops/ai-monitoring" element={<AIMonitoring />} />
|
||||||
<Route path="/ops/governance" element={<Governance />} />
|
<Route path="/ops/governance" element={<Governance />} />
|
||||||
|
<Route path="/ops/activity-timeline" element={<ActivityTimeline />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -28,9 +28,14 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Bell,
|
Clock,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import { OrganizationContextBadge } from './OrganizationContextBadge'
|
||||||
|
import { UserMenu } from './UserMenu'
|
||||||
|
import { NotificationButton } from './NotificationButton'
|
||||||
|
import { RightContextPanel } from './RightContextPanel'
|
||||||
|
import { CompareTray } from './CompareTray'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -93,6 +98,7 @@ const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
|
|||||||
{ path: '/ops/review-queue', label: 'Review Queue', icon: ClipboardList },
|
{ path: '/ops/review-queue', label: 'Review Queue', icon: ClipboardList },
|
||||||
{ path: '/ops/ai-monitoring', label: 'AI Monitoring', icon: Activity },
|
{ path: '/ops/ai-monitoring', label: 'AI Monitoring', icon: Activity },
|
||||||
{ path: '/ops/governance', label: 'Governance', icon: Shield },
|
{ path: '/ops/governance', label: 'Governance', icon: Shield },
|
||||||
|
{ path: '/ops/activity-timeline', label: 'Aktivitäts-Timeline', icon: Clock },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -458,6 +464,7 @@ function TopBar({ activeWorkspace, pathname }: TopBarProps) {
|
|||||||
|
|
||||||
{/* Right side */}
|
{/* Right side */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<OrganizationContextBadge />
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -466,12 +473,8 @@ function TopBar({ activeWorkspace, pathname }: TopBarProps) {
|
|||||||
>
|
>
|
||||||
AI Assistent
|
AI Assistent
|
||||||
</Button>
|
</Button>
|
||||||
<IconButton size="small" sx={{ color: '#64748b' }}>
|
<NotificationButton />
|
||||||
<Bell size={20} />
|
<UserMenu />
|
||||||
</IconButton>
|
|
||||||
<Avatar sx={{ width: 32, height: 32, fontSize: '0.75rem', bgcolor: '#1e3a5f' }}>
|
|
||||||
AU
|
|
||||||
</Avatar>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
@@ -521,7 +524,11 @@ export function AppShell() {
|
|||||||
<Box component="main" sx={{ flex: 1, overflowY: 'auto' }}>
|
<Box component="main" sx={{ flex: 1, overflowY: 'auto' }}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<RightContextPanel />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<CompareTray />
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
|
import { Box, Button, Chip, Typography } from '@mui/material'
|
||||||
|
import { useCompareStore } from '../../stores/compareStore'
|
||||||
|
import { useLayoutStore } from '../../stores/layoutStore'
|
||||||
|
|
||||||
|
export function CompareTray() {
|
||||||
|
const { compareTray, removeFromCompare, clearCompare } = useCompareStore()
|
||||||
|
const { setCompareTrayVisible } = useLayoutStore()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCompareTrayVisible(compareTray.length > 0)
|
||||||
|
}, [compareTray.length, setCompareTrayVisible])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1300,
|
||||||
|
height: 56,
|
||||||
|
bgcolor: '#0f1923',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
px: 3,
|
||||||
|
gap: 2,
|
||||||
|
transform: compareTray.length > 0 ? 'translateY(0)' : 'translateY(100%)',
|
||||||
|
transition: 'transform 0.25s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" sx={{ color: '#fff', flexShrink: 0 }}>
|
||||||
|
Vergleich ({compareTray.length}/3)
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, display: 'flex', gap: 1, overflow: 'hidden' }}>
|
||||||
|
{compareTray.map((id, i) => (
|
||||||
|
<Chip
|
||||||
|
key={id}
|
||||||
|
label={`Objekt ${i + 1}`}
|
||||||
|
size="small"
|
||||||
|
onDelete={() => removeFromCompare(id)}
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'rgba(255,255,255,0.15)',
|
||||||
|
color: '#fff',
|
||||||
|
'& .MuiChip-deleteIcon': { color: 'rgba(255,255,255,0.6)' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
onClick={clearCompare}
|
||||||
|
sx={{ color: 'rgba(255,255,255,0.5)', textTransform: 'none', flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
Alle entfernen
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
onClick={() => navigate('/demand/compare')}
|
||||||
|
sx={{ bgcolor: '#1e3a5f', textTransform: 'none', flexShrink: 0, '&:hover': { bgcolor: '#162d4a' } }}
|
||||||
|
>
|
||||||
|
Vergleich starten
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Badge, IconButton, Popover, Typography } from '@mui/material'
|
||||||
|
import { Bell } from 'lucide-react'
|
||||||
|
|
||||||
|
export function NotificationButton() {
|
||||||
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
||||||
|
|
||||||
|
function handleOpen(e: React.MouseEvent<HTMLElement>) {
|
||||||
|
setAnchorEl(e.currentTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
setAnchorEl(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<IconButton size="small" sx={{ color: '#64748b' }} onClick={handleOpen}>
|
||||||
|
<Badge badgeContent={0} color="error">
|
||||||
|
<Bell size={20} />
|
||||||
|
</Badge>
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<Popover
|
||||||
|
open={!!anchorEl}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
onClose={handleClose}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||||
|
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||||
|
slotProps={{ paper: { sx: { width: 280, p: 2 } } }}
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>Benachrichtigungen</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
Keine neuen Benachrichtigungen
|
||||||
|
</Typography>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Chip } from '@mui/material'
|
||||||
|
import { Building2 } from 'lucide-react'
|
||||||
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
|
|
||||||
|
export function OrganizationContextBadge() {
|
||||||
|
const { currentUser } = useSessionStore()
|
||||||
|
|
||||||
|
if (!currentUser?.organizationName) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
icon={<Building2 size={12} color="#64748b" />}
|
||||||
|
label={currentUser.organizationName}
|
||||||
|
sx={{ fontSize: '0.7rem', height: 22, color: '#64748b', borderColor: '#e2e8f0' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Box, Breadcrumbs, Typography } from '@mui/material'
|
||||||
|
import type { SxProps, Theme } from '@mui/material'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { NavLink } from 'react-router'
|
||||||
|
|
||||||
|
interface BreadcrumbItem {
|
||||||
|
label: string
|
||||||
|
href?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageHeaderProps {
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
breadcrumbs?: BreadcrumbItem[]
|
||||||
|
primaryAction?: ReactNode
|
||||||
|
secondaryActions?: ReactNode
|
||||||
|
badge?: ReactNode
|
||||||
|
sx?: SxProps<Theme>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageHeader({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
breadcrumbs,
|
||||||
|
primaryAction,
|
||||||
|
secondaryActions,
|
||||||
|
badge,
|
||||||
|
sx,
|
||||||
|
}: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ px: 3, py: 2.5, borderBottom: '1px solid #e2e8f0', ...sx }}>
|
||||||
|
{breadcrumbs && breadcrumbs.length > 0 && (
|
||||||
|
<Breadcrumbs separator="/" sx={{ mb: 1 }}>
|
||||||
|
{breadcrumbs.map((crumb) =>
|
||||||
|
crumb.href ? (
|
||||||
|
<NavLink
|
||||||
|
key={crumb.label}
|
||||||
|
to={crumb.href}
|
||||||
|
style={{ textDecoration: 'none', color: '#64748b', fontSize: '0.75rem' }}
|
||||||
|
>
|
||||||
|
{crumb.label}
|
||||||
|
</NavLink>
|
||||||
|
) : (
|
||||||
|
<Typography key={crumb.label} sx={{ fontSize: '0.75rem', color: 'text.secondary' }}>
|
||||||
|
{crumb.label}
|
||||||
|
</Typography>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</Breadcrumbs>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 600, lineHeight: 1.3 }}>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
{badge}
|
||||||
|
</Box>
|
||||||
|
{subtitle && (
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.25 }}>
|
||||||
|
{subtitle}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{(primaryAction ?? secondaryActions) && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0, ml: 2 }}>
|
||||||
|
{secondaryActions}
|
||||||
|
{primaryAction}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Box, Divider, IconButton, Typography } from '@mui/material'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { useLayoutStore } from '../../stores/layoutStore'
|
||||||
|
import type { RightPanelContentType } from '../../stores/layoutStore'
|
||||||
|
|
||||||
|
const PANEL_TITLES: Record<RightPanelContentType, string> = {
|
||||||
|
ai_context: 'KI Kontext',
|
||||||
|
detail_preview: 'Detail Vorschau',
|
||||||
|
compare_preview: 'Vergleich Vorschau',
|
||||||
|
activity_feed: 'Aktivitäts-Feed',
|
||||||
|
}
|
||||||
|
|
||||||
|
const PANEL_PLACEHOLDERS: Record<RightPanelContentType, string> = {
|
||||||
|
ai_context: 'KI-Kontext wird geladen...',
|
||||||
|
detail_preview: 'Kein Objekt ausgewählt.',
|
||||||
|
compare_preview: 'Vergleichsvorschau nicht verfügbar.',
|
||||||
|
activity_feed: 'Keine Aktivitäten vorhanden.',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RightContextPanel() {
|
||||||
|
const { isRightPanelOpen, rightPanelContentType, closeRightPanel } = useLayoutStore()
|
||||||
|
|
||||||
|
const title = rightPanelContentType ? PANEL_TITLES[rightPanelContentType] : ''
|
||||||
|
const placeholder = rightPanelContentType ? PANEL_PLACEHOLDERS[rightPanelContentType] : ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
right: 0,
|
||||||
|
top: 56,
|
||||||
|
height: 'calc(100vh - 56px)',
|
||||||
|
width: 320,
|
||||||
|
transform: isRightPanelOpen ? 'translateX(0)' : 'translateX(320px)',
|
||||||
|
transition: 'transform 0.25s ease',
|
||||||
|
bgcolor: '#fff',
|
||||||
|
borderLeft: '1px solid #e2e8f0',
|
||||||
|
boxShadow: '-4px 0 16px rgba(0,0,0,0.08)',
|
||||||
|
zIndex: 1200,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: 48,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
px: 2,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>{title}</Typography>
|
||||||
|
<IconButton size="small" onClick={closeRightPanel} sx={{ color: '#64748b' }}>
|
||||||
|
<X size={16} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, overflowY: 'auto', p: 2 }}>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
|
{placeholder}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
|
import { Avatar, Divider, IconButton, ListItemIcon, Menu, MenuItem, Typography } from '@mui/material'
|
||||||
|
import { LogOut, Settings, User } from 'lucide-react'
|
||||||
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
|
|
||||||
|
function getUserInitials(name: string): string {
|
||||||
|
return name
|
||||||
|
.split(' ')
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserMenu() {
|
||||||
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
||||||
|
const { currentUser, logout } = useSessionStore()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
function handleOpen(e: React.MouseEvent<HTMLElement>) {
|
||||||
|
setAnchorEl(e.currentTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
setAnchorEl(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
handleClose()
|
||||||
|
logout()
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const initials = getUserInitials(currentUser?.name ?? 'U')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<IconButton onClick={handleOpen} size="small" sx={{ p: 0 }}>
|
||||||
|
<Avatar sx={{ width: 32, height: 32, fontSize: '0.75rem', bgcolor: '#1e3a5f' }}>
|
||||||
|
{initials}
|
||||||
|
</Avatar>
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<Menu
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
open={!!anchorEl}
|
||||||
|
onClose={handleClose}
|
||||||
|
slotProps={{ paper: { sx: { width: 200, mt: 1 } } }}
|
||||||
|
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||||
|
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||||
|
>
|
||||||
|
{currentUser && (
|
||||||
|
<MenuItem disabled sx={{ opacity: '1 !important' }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{currentUser.name}</Typography>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
<MenuItem onClick={handleClose}>
|
||||||
|
<ListItemIcon><User size={16} /></ListItemIcon>
|
||||||
|
Profil
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={handleClose}>
|
||||||
|
<ListItemIcon><Settings size={16} /></ListItemIcon>
|
||||||
|
Einstellungen
|
||||||
|
</MenuItem>
|
||||||
|
<Divider />
|
||||||
|
<MenuItem onClick={handleLogout}>
|
||||||
|
<ListItemIcon><LogOut size={16} /></ListItemIcon>
|
||||||
|
Abmelden
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1 +1,7 @@
|
|||||||
export { AppShell } from './AppShell'
|
export { AppShell } from './AppShell'
|
||||||
|
export { PageHeader } from './PageHeader'
|
||||||
|
export { RightContextPanel } from './RightContextPanel'
|
||||||
|
export { CompareTray } from './CompareTray'
|
||||||
|
export { UserMenu } from './UserMenu'
|
||||||
|
export { NotificationButton } from './NotificationButton'
|
||||||
|
export { OrganizationContextBadge } from './OrganizationContextBadge'
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Box, Typography } from '@mui/material'
|
||||||
|
import { PageHeader } from '../../components/layout'
|
||||||
|
|
||||||
|
export default function ActivityTimeline() {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<PageHeader
|
||||||
|
title="Aktivitäts-Timeline"
|
||||||
|
subtitle="Verlauf aller System- und Benutzeraktionen"
|
||||||
|
/>
|
||||||
|
<Typography color="text.secondary" sx={{ mt: 3 }}>
|
||||||
|
Timeline wird implementiert...
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user