From 9a2476c58a242c11d5649fbf299a30f667857310 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Fri, 15 May 2026 11:50:06 +0200 Subject: [PATCH] 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 --- src/App.tsx | 2 + src/components/layout/AppShell.tsx | 21 +++-- src/components/layout/CompareTray.tsx | 73 ++++++++++++++++++ src/components/layout/NotificationButton.tsx | 39 ++++++++++ .../layout/OrganizationContextBadge.tsx | 19 +++++ src/components/layout/PageHeader.tsx | 76 +++++++++++++++++++ src/components/layout/RightContextPanel.tsx | 70 +++++++++++++++++ src/components/layout/UserMenu.tsx | 74 ++++++++++++++++++ src/components/layout/index.ts | 6 ++ src/pages/ops/ActivityTimeline.tsx | 16 ++++ 10 files changed, 389 insertions(+), 7 deletions(-) create mode 100644 src/components/layout/CompareTray.tsx create mode 100644 src/components/layout/NotificationButton.tsx create mode 100644 src/components/layout/OrganizationContextBadge.tsx create mode 100644 src/components/layout/PageHeader.tsx create mode 100644 src/components/layout/RightContextPanel.tsx create mode 100644 src/components/layout/UserMenu.tsx create mode 100644 src/pages/ops/ActivityTimeline.tsx diff --git a/src/App.tsx b/src/App.tsx index a1d85a6..c752a1f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ const Shortlists = lazy(() => import('./pages/demand/Shortlists')) const ReviewQueue = lazy(() => import('./pages/ops/ReviewQueue')) const AIMonitoring = lazy(() => import('./pages/ops/AIMonitoring')) const Governance = lazy(() => import('./pages/ops/Governance')) +const ActivityTimeline = lazy(() => import('./pages/ops/ActivityTimeline')) function App() { return ( @@ -43,6 +44,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 4a7a722..08c53ca 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -28,9 +28,14 @@ import { ChevronLeft, ChevronRight, Sparkles, - Bell, + Clock, } 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 @@ -93,6 +98,7 @@ const WORKSPACE_CONFIG: Record = { { path: '/ops/review-queue', label: 'Review Queue', icon: ClipboardList }, { path: '/ops/ai-monitoring', label: 'AI Monitoring', icon: Activity }, { 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 */} + - - - - - AU - + + ) @@ -521,7 +524,11 @@ export function AppShell() { + + + + ) } diff --git a/src/components/layout/CompareTray.tsx b/src/components/layout/CompareTray.tsx new file mode 100644 index 0000000..ebd899b --- /dev/null +++ b/src/components/layout/CompareTray.tsx @@ -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 ( + 0 ? 'translateY(0)' : 'translateY(100%)', + transition: 'transform 0.25s ease', + }} + > + + Vergleich ({compareTray.length}/3) + + + + {compareTray.map((id, i) => ( + removeFromCompare(id)} + sx={{ + bgcolor: 'rgba(255,255,255,0.15)', + color: '#fff', + '& .MuiChip-deleteIcon': { color: 'rgba(255,255,255,0.6)' }, + }} + /> + ))} + + + + + + + ) +} diff --git a/src/components/layout/NotificationButton.tsx b/src/components/layout/NotificationButton.tsx new file mode 100644 index 0000000..92ffbed --- /dev/null +++ b/src/components/layout/NotificationButton.tsx @@ -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) + + function handleOpen(e: React.MouseEvent) { + setAnchorEl(e.currentTarget) + } + + function handleClose() { + setAnchorEl(null) + } + + return ( + <> + + + + + + + + Benachrichtigungen + + Keine neuen Benachrichtigungen + + + + ) +} diff --git a/src/components/layout/OrganizationContextBadge.tsx b/src/components/layout/OrganizationContextBadge.tsx new file mode 100644 index 0000000..0ca238c --- /dev/null +++ b/src/components/layout/OrganizationContextBadge.tsx @@ -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 ( + } + label={currentUser.organizationName} + sx={{ fontSize: '0.7rem', height: 22, color: '#64748b', borderColor: '#e2e8f0' }} + /> + ) +} diff --git a/src/components/layout/PageHeader.tsx b/src/components/layout/PageHeader.tsx new file mode 100644 index 0000000..174f722 --- /dev/null +++ b/src/components/layout/PageHeader.tsx @@ -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 +} + +export function PageHeader({ + title, + subtitle, + breadcrumbs, + primaryAction, + secondaryActions, + badge, + sx, +}: PageHeaderProps) { + return ( + + {breadcrumbs && breadcrumbs.length > 0 && ( + + {breadcrumbs.map((crumb) => + crumb.href ? ( + + {crumb.label} + + ) : ( + + {crumb.label} + + ), + )} + + )} + + + + + + {title} + + {badge} + + {subtitle && ( + + {subtitle} + + )} + + + {(primaryAction ?? secondaryActions) && ( + + {secondaryActions} + {primaryAction} + + )} + + + ) +} diff --git a/src/components/layout/RightContextPanel.tsx b/src/components/layout/RightContextPanel.tsx new file mode 100644 index 0000000..cb6d123 --- /dev/null +++ b/src/components/layout/RightContextPanel.tsx @@ -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 = { + ai_context: 'KI Kontext', + detail_preview: 'Detail Vorschau', + compare_preview: 'Vergleich Vorschau', + activity_feed: 'Aktivitäts-Feed', +} + +const PANEL_PLACEHOLDERS: Record = { + 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 ( + + + {title} + + + + + + + + + + {placeholder} + + + + ) +} diff --git a/src/components/layout/UserMenu.tsx b/src/components/layout/UserMenu.tsx new file mode 100644 index 0000000..cff3b95 --- /dev/null +++ b/src/components/layout/UserMenu.tsx @@ -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) + const { currentUser, logout } = useSessionStore() + const navigate = useNavigate() + + function handleOpen(e: React.MouseEvent) { + setAnchorEl(e.currentTarget) + } + + function handleClose() { + setAnchorEl(null) + } + + function handleLogout() { + handleClose() + logout() + navigate('/') + } + + const initials = getUserInitials(currentUser?.name ?? 'U') + + return ( + <> + + + {initials} + + + + + {currentUser && ( + + {currentUser.name} + + )} + + + Profil + + + + Einstellungen + + + + + Abmelden + + + + ) +} diff --git a/src/components/layout/index.ts b/src/components/layout/index.ts index 391bbd0..90cbbd0 100644 --- a/src/components/layout/index.ts +++ b/src/components/layout/index.ts @@ -1 +1,7 @@ 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' diff --git a/src/pages/ops/ActivityTimeline.tsx b/src/pages/ops/ActivityTimeline.tsx new file mode 100644 index 0000000..bdcedd8 --- /dev/null +++ b/src/pages/ops/ActivityTimeline.tsx @@ -0,0 +1,16 @@ +import { Box, Typography } from '@mui/material' +import { PageHeader } from '../../components/layout' + +export default function ActivityTimeline() { + return ( + + + + Timeline wird implementiert... + + + ) +}