544 lines
16 KiB
TypeScript
544 lines
16 KiB
TypeScript
import { useEffect } from 'react'
|
|
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router'
|
|
import { useLayoutStore } from '../../stores/layoutStore'
|
|
import { useSessionStore } from '../../stores/sessionStore'
|
|
import { WorkspaceType } from '../../domain/enums'
|
|
import {
|
|
Box,
|
|
Typography,
|
|
Avatar,
|
|
IconButton,
|
|
Tooltip,
|
|
Chip,
|
|
Button,
|
|
} from '@mui/material'
|
|
import {
|
|
LayoutDashboard,
|
|
Building2,
|
|
Target,
|
|
TrendingUp,
|
|
CheckSquare,
|
|
Search,
|
|
List,
|
|
Columns2,
|
|
Bookmark,
|
|
ClipboardList,
|
|
Activity,
|
|
Shield,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Sparkles,
|
|
Clock,
|
|
Radar,
|
|
ServerCog,
|
|
} 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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface NavItem {
|
|
path: string
|
|
label: string
|
|
icon: LucideIcon
|
|
}
|
|
|
|
interface WorkspaceConfig {
|
|
label: string
|
|
abbreviation: string
|
|
icon: LucideIcon
|
|
firstPath: string
|
|
navItems: NavItem[]
|
|
chipColor: string
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Workspace configuration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
|
|
[WorkspaceType.SUPPLY]: {
|
|
label: 'Supply',
|
|
abbreviation: 'SUP',
|
|
icon: Building2,
|
|
firstPath: '/supply/dashboard',
|
|
chipColor: '#1e3a5f',
|
|
navItems: [
|
|
{ path: '/supply/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
|
{ path: '/supply/properties', label: 'Objekte', icon: Building2 },
|
|
{ path: '/supply/match-center', label: 'Match Center', icon: Target },
|
|
{ path: '/supply/future-availability', label: 'Marktchancen', icon: TrendingUp },
|
|
{ path: '/supply/data-quality', label: 'Datenqualität', icon: CheckSquare },
|
|
],
|
|
},
|
|
[WorkspaceType.DEMAND]: {
|
|
label: 'Demand',
|
|
abbreviation: 'DEM',
|
|
icon: Search,
|
|
firstPath: '/demand/ai-search',
|
|
chipColor: '#1a7a4a',
|
|
navItems: [
|
|
{ path: '/demand/ai-search', label: 'AI Suche', icon: Search },
|
|
{ path: '/demand/results', label: 'Ergebnisse', icon: List },
|
|
{ path: '/demand/compare', label: 'Vergleich', icon: Columns2 },
|
|
{ path: '/demand/shortlists', label: 'Shortlists', icon: Bookmark },
|
|
],
|
|
},
|
|
[WorkspaceType.OPERATIONS]: {
|
|
label: 'Operations',
|
|
abbreviation: 'OPS',
|
|
icon: Shield,
|
|
firstPath: '/ops/review-queue',
|
|
chipColor: '#7c3aed',
|
|
navItems: [
|
|
{ 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/market-intelligence', label: 'Market Intelligence', icon: Radar },
|
|
{ path: '/ops/source-monitoring', label: 'Source Monitoring', icon: ServerCog },
|
|
{ path: '/ops/activity-timeline', label: 'Aktivitäts-Timeline', icon: Clock },
|
|
],
|
|
},
|
|
}
|
|
|
|
// Ordered list for rendering workspace tabs
|
|
const WORKSPACE_ORDER: WorkspaceType[] = [
|
|
WorkspaceType.SUPPLY,
|
|
WorkspaceType.DEMAND,
|
|
WorkspaceType.OPERATIONS,
|
|
]
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function getWorkspaceFromPath(pathname: string): WorkspaceType | null {
|
|
if (pathname.startsWith('/supply')) return WorkspaceType.SUPPLY
|
|
if (pathname.startsWith('/demand')) return WorkspaceType.DEMAND
|
|
if (pathname.startsWith('/ops')) return WorkspaceType.OPERATIONS
|
|
return null
|
|
}
|
|
|
|
function getPageNameFromPath(pathname: string): string {
|
|
for (const ws of Object.values(WORKSPACE_CONFIG)) {
|
|
for (const item of ws.navItems) {
|
|
if (item.path === pathname) return item.label
|
|
}
|
|
}
|
|
// Fallback: last segment, capitalised
|
|
const segment = pathname.split('/').filter(Boolean).pop() ?? ''
|
|
return segment.charAt(0).toUpperCase() + segment.slice(1).replace(/-/g, ' ')
|
|
}
|
|
|
|
function getUserInitials(name: string): string {
|
|
return name
|
|
.split(' ')
|
|
.map((n) => n[0])
|
|
.join('')
|
|
.toUpperCase()
|
|
.slice(0, 2)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sub-components
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const SIDEBAR_BG = '#0f1923'
|
|
const DIVIDER_COLOR = 'rgba(255,255,255,0.08)'
|
|
const TEXT_MUTED = '#94a3b8'
|
|
const TEXT_WHITE = '#ffffff'
|
|
const ACTIVE_BG = 'rgba(255,255,255,0.1)'
|
|
const NAV_ACTIVE_BG = 'rgba(255,255,255,0.12)'
|
|
const NAV_HOVER_BG = 'rgba(255,255,255,0.06)'
|
|
|
|
interface SidebarProps {
|
|
collapsed: boolean
|
|
activeWorkspace: WorkspaceType
|
|
allowedWorkspaces: WorkspaceType[]
|
|
onWorkspaceClick: (workspace: WorkspaceType) => void
|
|
onToggle: () => void
|
|
userName: string
|
|
orgName: string
|
|
}
|
|
|
|
function Sidebar({
|
|
collapsed,
|
|
activeWorkspace,
|
|
allowedWorkspaces,
|
|
onWorkspaceClick,
|
|
onToggle,
|
|
userName,
|
|
orgName,
|
|
}: SidebarProps) {
|
|
const config = WORKSPACE_CONFIG[activeWorkspace]
|
|
const width = collapsed ? 60 : 240
|
|
const visibleWorkspaces = WORKSPACE_ORDER.filter((ws) => allowedWorkspaces.includes(ws))
|
|
|
|
return (
|
|
<Box
|
|
component="nav"
|
|
sx={{
|
|
width,
|
|
minWidth: width,
|
|
flexShrink: 0,
|
|
height: '100vh',
|
|
backgroundColor: SIDEBAR_BG,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
transition: 'width 0.2s ease',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
{/* Logo area */}
|
|
<Box
|
|
sx={{
|
|
height: 56,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: collapsed ? 'center' : 'flex-start',
|
|
px: collapsed ? 0 : 2.5,
|
|
borderBottom: `1px solid ${DIVIDER_COLOR}`,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{collapsed ? (
|
|
<Typography
|
|
variant="subtitle1"
|
|
sx={{ color: TEXT_WHITE, fontWeight: 700, letterSpacing: 0.5 }}
|
|
>
|
|
PM
|
|
</Typography>
|
|
) : (
|
|
<Box>
|
|
<Typography
|
|
variant="subtitle1"
|
|
sx={{ color: TEXT_WHITE, fontWeight: 700, lineHeight: 1.2 }}
|
|
>
|
|
Property
|
|
</Typography>
|
|
<Typography
|
|
variant="caption"
|
|
sx={{
|
|
color: '#64b5f6',
|
|
fontWeight: 600,
|
|
textTransform: 'uppercase',
|
|
letterSpacing: 1,
|
|
fontSize: '0.6rem',
|
|
}}
|
|
>
|
|
Match
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
|
|
{/* Workspace tabs */}
|
|
<Box
|
|
sx={{
|
|
borderBottom: `1px solid ${DIVIDER_COLOR}`,
|
|
py: 0.5,
|
|
px: 0.5,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{visibleWorkspaces.map((ws) => {
|
|
const wsConfig = WORKSPACE_CONFIG[ws]
|
|
const Icon = wsConfig.icon
|
|
const isActive = ws === activeWorkspace
|
|
|
|
const tabContent = (
|
|
<Box
|
|
onClick={() => onWorkspaceClick(ws)}
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 1.25,
|
|
px: collapsed ? 0 : 1.5,
|
|
py: 0.75,
|
|
borderRadius: 1,
|
|
cursor: 'pointer',
|
|
justifyContent: collapsed ? 'center' : 'flex-start',
|
|
backgroundColor: isActive ? ACTIVE_BG : 'transparent',
|
|
'&:hover': {
|
|
backgroundColor: isActive ? ACTIVE_BG : NAV_HOVER_BG,
|
|
},
|
|
transition: 'background-color 0.15s ease',
|
|
}}
|
|
>
|
|
<Icon size={16} color={isActive ? TEXT_WHITE : TEXT_MUTED} />
|
|
{!collapsed && (
|
|
<Typography
|
|
variant="body2"
|
|
sx={{
|
|
color: isActive ? TEXT_WHITE : TEXT_MUTED,
|
|
fontWeight: isActive ? 600 : 400,
|
|
fontSize: '0.8125rem',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{wsConfig.label}
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
)
|
|
|
|
return collapsed ? (
|
|
<Tooltip key={ws} title={wsConfig.label} placement="right">
|
|
{tabContent}
|
|
</Tooltip>
|
|
) : (
|
|
<Box key={ws}>{tabContent}</Box>
|
|
)
|
|
})}
|
|
</Box>
|
|
|
|
{/* Nav items */}
|
|
<Box sx={{ flex: 1, overflowY: 'auto', py: 0.5 }}>
|
|
{config.navItems.map((item) => {
|
|
const Icon = item.icon
|
|
|
|
const navContent = (
|
|
<NavLink
|
|
to={item.path}
|
|
style={{ textDecoration: 'none', display: 'block' }}
|
|
>
|
|
{({ isActive }) => (
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 1.25,
|
|
px: collapsed ? 0 : 1.5,
|
|
py: 0.75,
|
|
mx: 0.5,
|
|
borderRadius: 1,
|
|
justifyContent: collapsed ? 'center' : 'flex-start',
|
|
backgroundColor: isActive ? NAV_ACTIVE_BG : 'transparent',
|
|
'&:hover': {
|
|
backgroundColor: isActive ? NAV_ACTIVE_BG : NAV_HOVER_BG,
|
|
},
|
|
transition: 'background-color 0.15s ease',
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
<Icon size={16} color={isActive ? TEXT_WHITE : TEXT_MUTED} />
|
|
{!collapsed && (
|
|
<Typography
|
|
variant="body2"
|
|
sx={{
|
|
color: isActive ? TEXT_WHITE : TEXT_MUTED,
|
|
fontWeight: isActive ? 500 : 400,
|
|
fontSize: '0.8125rem',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{item.label}
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
)}
|
|
</NavLink>
|
|
)
|
|
|
|
return collapsed ? (
|
|
<Tooltip key={item.path} title={item.label} placement="right">
|
|
<Box>{navContent}</Box>
|
|
</Tooltip>
|
|
) : (
|
|
<Box key={item.path}>{navContent}</Box>
|
|
)
|
|
})}
|
|
</Box>
|
|
|
|
{/* Bottom section */}
|
|
<Box
|
|
sx={{
|
|
borderTop: `1px solid ${DIVIDER_COLOR}`,
|
|
px: collapsed ? 0 : 1.5,
|
|
py: 1,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 1,
|
|
justifyContent: collapsed ? 'center' : 'space-between',
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{!collapsed && (
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
|
|
<Avatar
|
|
sx={{
|
|
width: 32,
|
|
height: 32,
|
|
fontSize: '0.75rem',
|
|
bgcolor: '#1e3a5f',
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{getUserInitials(userName)}
|
|
</Avatar>
|
|
<Box sx={{ minWidth: 0 }}>
|
|
<Typography
|
|
variant="body2"
|
|
sx={{
|
|
color: TEXT_WHITE,
|
|
fontWeight: 500,
|
|
fontSize: '0.8125rem',
|
|
whiteSpace: 'nowrap',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
}}
|
|
>
|
|
{userName}
|
|
</Typography>
|
|
<Typography
|
|
variant="caption"
|
|
sx={{
|
|
color: '#64748b',
|
|
fontSize: '0.7rem',
|
|
whiteSpace: 'nowrap',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
display: 'block',
|
|
}}
|
|
>
|
|
{orgName}
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
|
|
<IconButton
|
|
onClick={onToggle}
|
|
size="small"
|
|
sx={{ color: '#64748b', flexShrink: 0 }}
|
|
>
|
|
{collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
|
|
</IconButton>
|
|
</Box>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
interface TopBarProps {
|
|
activeWorkspace: WorkspaceType
|
|
pathname: string
|
|
}
|
|
|
|
function TopBar({ activeWorkspace, pathname }: TopBarProps) {
|
|
const config = WORKSPACE_CONFIG[activeWorkspace]
|
|
const pageName = getPageNameFromPath(pathname)
|
|
|
|
return (
|
|
<Box
|
|
component="header"
|
|
sx={{
|
|
height: 56,
|
|
flexShrink: 0,
|
|
backgroundColor: '#ffffff',
|
|
borderBottom: '1px solid #e2e8f0',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
px: 3,
|
|
}}
|
|
>
|
|
{/* Left side */}
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
|
<Chip
|
|
label={config.label}
|
|
size="small"
|
|
sx={{
|
|
backgroundColor: config.chipColor,
|
|
color: '#ffffff',
|
|
fontWeight: 600,
|
|
fontSize: '0.7rem',
|
|
height: 22,
|
|
}}
|
|
/>
|
|
<Typography
|
|
variant="body1"
|
|
sx={{ fontWeight: 500, color: '#1e293b', fontSize: '0.9375rem' }}
|
|
>
|
|
{pageName}
|
|
</Typography>
|
|
</Box>
|
|
|
|
{/* Right side */}
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<OrganizationContextBadge />
|
|
<Button
|
|
variant="outlined"
|
|
size="small"
|
|
startIcon={<Sparkles size={14} />}
|
|
sx={{ textTransform: 'none', fontSize: '0.8125rem' }}
|
|
>
|
|
AI Assistent
|
|
</Button>
|
|
<NotificationButton />
|
|
<UserMenu />
|
|
</Box>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// AppShell
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function AppShell() {
|
|
const { activeWorkspace, sidebarCollapsed, setActiveWorkspace, toggleSidebar } =
|
|
useLayoutStore()
|
|
const { currentUser } = useSessionStore()
|
|
const navigate = useNavigate()
|
|
const location = useLocation()
|
|
|
|
// Sync active workspace with URL
|
|
useEffect(() => {
|
|
const detected = getWorkspaceFromPath(location.pathname)
|
|
if (detected && detected !== activeWorkspace) {
|
|
setActiveWorkspace(detected)
|
|
}
|
|
}, [location.pathname, activeWorkspace, setActiveWorkspace])
|
|
|
|
const handleWorkspaceClick = (workspace: WorkspaceType) => {
|
|
setActiveWorkspace(workspace)
|
|
navigate(WORKSPACE_CONFIG[workspace].firstPath)
|
|
}
|
|
|
|
const userName = currentUser?.name ?? 'User'
|
|
const orgName = currentUser?.organizationName ?? ''
|
|
const allowedWorkspaces = currentUser?.allowedWorkspaces ?? WORKSPACE_ORDER
|
|
|
|
return (
|
|
<Box sx={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
|
|
<Sidebar
|
|
collapsed={sidebarCollapsed}
|
|
activeWorkspace={activeWorkspace}
|
|
allowedWorkspaces={allowedWorkspaces}
|
|
onWorkspaceClick={handleWorkspaceClick}
|
|
onToggle={toggleSidebar}
|
|
userName={userName}
|
|
orgName={orgName}
|
|
/>
|
|
|
|
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
|
<TopBar activeWorkspace={activeWorkspace} pathname={location.pathname} />
|
|
|
|
<Box component="main" sx={{ flex: 1, overflowY: 'auto' }}>
|
|
<Outlet />
|
|
</Box>
|
|
|
|
<RightContextPanel />
|
|
</Box>
|
|
|
|
<CompareTray />
|
|
</Box>
|
|
)
|
|
}
|