487 lines
19 KiB
TypeScript
487 lines
19 KiB
TypeScript
import React, { createContext, useContext, useState, useEffect } from 'react';
|
|
import { allPageData, SidebarItem } from './data';
|
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
|
import { resolveLanguageText, GenericPageData } from './pageInterface';
|
|
import { usePermissions } from '../../hooks/usePermissions';
|
|
import { FaHome, FaHatWizard, FaBriefcase, FaBuilding } from 'react-icons/fa';
|
|
import { RiFolderSettingsFill } from 'react-icons/ri';
|
|
import { SidebarSubmenuItemData } from '../../components/Sidebar/sidebarTypes';
|
|
|
|
// Configuration for parent groups that don't have a page definition
|
|
// Maps parentPath (can be nested like "start.real-estate") to icon and default order
|
|
const parentGroupConfig: Record<string, {
|
|
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
|
defaultOrder?: number;
|
|
}> = {
|
|
'start': {
|
|
icon: FaHome,
|
|
defaultOrder: 1
|
|
},
|
|
'start.real-estate': {
|
|
icon: FaBuilding,
|
|
defaultOrder: 1
|
|
},
|
|
'start.trustee': {
|
|
icon: FaBriefcase,
|
|
defaultOrder: 2
|
|
},
|
|
'trustee': {
|
|
icon: FaBriefcase,
|
|
defaultOrder: 2
|
|
},
|
|
'administration': {
|
|
icon: RiFolderSettingsFill,
|
|
defaultOrder: 3
|
|
},
|
|
'admin': {
|
|
icon: FaHatWizard,
|
|
defaultOrder: 4
|
|
}
|
|
};
|
|
|
|
interface SidebarContextType {
|
|
sidebarItems: SidebarItem[];
|
|
loading: boolean;
|
|
error: string | null;
|
|
refreshSidebar: () => Promise<void>;
|
|
}
|
|
|
|
const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
|
|
|
|
export const useSidebar = () => {
|
|
const context = useContext(SidebarContext);
|
|
if (!context) {
|
|
throw new Error('useSidebar must be used within a SidebarProvider');
|
|
}
|
|
return context;
|
|
};
|
|
|
|
interface SidebarProviderProps {
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
export const SidebarProvider: React.FC<SidebarProviderProps> = ({ children }) => {
|
|
const [sidebarItems, setSidebarItems] = useState<SidebarItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Get translation function from language context
|
|
const { t } = useLanguage();
|
|
const { canView, preloadUiPermissions } = usePermissions();
|
|
|
|
// Helper type for navigation tree nodes
|
|
interface NavigationNode {
|
|
id: string;
|
|
pathSegment: string;
|
|
fullPath: string; // Full dot-notation path (e.g., "start.real-estate")
|
|
name: string;
|
|
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
|
order: number;
|
|
page?: GenericPageData; // If this node represents an actual page
|
|
children: Map<string, NavigationNode>; // Keyed by path segment
|
|
pages: GenericPageData[]; // Direct child pages
|
|
}
|
|
|
|
// Helper function to resolve node name
|
|
const resolveNodeName = (pathSegment: string, fullPath: string, page?: GenericPageData): string => {
|
|
if (page) {
|
|
return resolveLanguageText(page.name, t);
|
|
}
|
|
// Try translation key (e.g., "start.real-estate.title")
|
|
const translationKey = `${fullPath}.title`;
|
|
const translated = t(translationKey);
|
|
if (translated !== translationKey) {
|
|
return translated;
|
|
}
|
|
// Try just the segment (e.g., "real-estate.title")
|
|
const segmentKey = `${pathSegment}.title`;
|
|
const segmentTranslated = t(segmentKey);
|
|
if (segmentTranslated !== segmentKey) {
|
|
return segmentTranslated;
|
|
}
|
|
// Fallback to capitalized segment
|
|
return pathSegment.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(' ');
|
|
};
|
|
|
|
// Helper function to resolve node icon
|
|
const resolveNodeIcon = (pathSegment: string, fullPath: string, page?: GenericPageData): React.ComponentType<React.SVGProps<SVGSVGElement>> | undefined => {
|
|
if (page?.icon) {
|
|
return page.icon;
|
|
}
|
|
// Check parentGroupConfig for nested paths first (e.g., "start.real-estate")
|
|
if (parentGroupConfig[fullPath]?.icon) {
|
|
return parentGroupConfig[fullPath].icon;
|
|
}
|
|
// Check parentGroupConfig for top-level segments (e.g., "start")
|
|
if (fullPath === pathSegment && parentGroupConfig[pathSegment]?.icon) {
|
|
return parentGroupConfig[pathSegment].icon;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
// Helper function to resolve node order
|
|
const resolveNodeOrder = (pathSegment: string, fullPath: string, page?: GenericPageData, childPages: GenericPageData[] = []): number => {
|
|
if (page?.order !== undefined) {
|
|
return page.order;
|
|
}
|
|
// Check parentGroupConfig for top-level segments
|
|
if (fullPath === pathSegment && parentGroupConfig[pathSegment]?.defaultOrder !== undefined) {
|
|
return parentGroupConfig[pathSegment].defaultOrder!;
|
|
}
|
|
// Use minimum order of child pages
|
|
if (childPages.length > 0) {
|
|
const childOrders = childPages.map(p => p.order ?? 0);
|
|
return Math.min(...childOrders);
|
|
}
|
|
return 0;
|
|
};
|
|
|
|
// Build navigation tree from page data
|
|
const buildNavigationTree = (): Map<string, NavigationNode> => {
|
|
const rootNodes = new Map<string, NavigationNode>();
|
|
|
|
// Process all pages with parent paths
|
|
const pagesWithParents = allPageData.filter(
|
|
page => page.parentPath && !page.hide && page.showInSidebar !== false
|
|
);
|
|
|
|
for (const page of pagesWithParents) {
|
|
if (!page.parentPath) continue;
|
|
|
|
// Parse parent path segments (e.g., "start.real-estate" -> ["start", "real-estate"])
|
|
const pathSegments = page.parentPath.split('.');
|
|
|
|
// Build path to root, creating nodes as needed
|
|
let currentMap = rootNodes;
|
|
let currentFullPath = '';
|
|
|
|
for (let i = 0; i < pathSegments.length; i++) {
|
|
const segment = pathSegments[i];
|
|
currentFullPath = currentFullPath ? `${currentFullPath}.${segment}` : segment;
|
|
|
|
// Get or create node for this segment
|
|
if (!currentMap.has(segment)) {
|
|
// Check if there's a page for this path segment
|
|
const segmentPage = allPageData.find(
|
|
p => p.path === currentFullPath && !p.hide
|
|
);
|
|
|
|
const node: NavigationNode = {
|
|
id: segmentPage?.id || currentFullPath,
|
|
pathSegment: segment,
|
|
fullPath: currentFullPath,
|
|
name: '', // Will be resolved later
|
|
icon: undefined, // Will be resolved later
|
|
order: 0, // Will be resolved later
|
|
page: segmentPage,
|
|
children: new Map(),
|
|
pages: []
|
|
};
|
|
|
|
currentMap.set(segment, node);
|
|
}
|
|
|
|
const node = currentMap.get(segment)!;
|
|
|
|
// If this is the last segment, add the page as a child page
|
|
if (i === pathSegments.length - 1) {
|
|
node.pages.push(page);
|
|
}
|
|
|
|
// Move to next level
|
|
currentMap = node.children;
|
|
}
|
|
}
|
|
|
|
// Resolve names, icons, and orders for all nodes
|
|
const resolveNode = (node: NavigationNode): void => {
|
|
// Resolve children first (bottom-up)
|
|
for (const childNode of node.children.values()) {
|
|
resolveNode(childNode);
|
|
}
|
|
|
|
// Resolve this node
|
|
node.name = resolveNodeName(node.pathSegment, node.fullPath, node.page);
|
|
node.icon = resolveNodeIcon(node.pathSegment, node.fullPath, node.page);
|
|
|
|
// Collect all child pages (from direct pages and nested children)
|
|
const allChildPages = [...node.pages];
|
|
for (const childNode of node.children.values()) {
|
|
if (childNode.page) {
|
|
allChildPages.push(childNode.page);
|
|
}
|
|
allChildPages.push(...childNode.pages);
|
|
}
|
|
|
|
node.order = resolveNodeOrder(node.pathSegment, node.fullPath, node.page, allChildPages);
|
|
};
|
|
|
|
// Resolve all root nodes
|
|
for (const node of rootNodes.values()) {
|
|
resolveNode(node);
|
|
}
|
|
|
|
return rootNodes;
|
|
};
|
|
|
|
// Convert navigation tree node to sidebar submenu item (recursive)
|
|
const nodeToSubmenuItem = async (node: NavigationNode, depth: number = 0): Promise<SidebarSubmenuItemData | null> => {
|
|
// Filter child pages by RBAC and privilegeChecker
|
|
const accessiblePages: GenericPageData[] = [];
|
|
for (const page of node.pages) {
|
|
try {
|
|
const hasRBACAccess = await canView('UI', page.path);
|
|
if (!hasRBACAccess) continue;
|
|
|
|
if (page.privilegeChecker) {
|
|
try {
|
|
const hasPrivilege = await page.privilegeChecker();
|
|
if (!hasPrivilege) continue;
|
|
} catch (error) {
|
|
console.error(`Error checking privilegeChecker for page ${page.path}:`, error);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
accessiblePages.push(page);
|
|
} catch (error) {
|
|
console.error(`Error checking RBAC access for page ${page.path}:`, error);
|
|
}
|
|
}
|
|
|
|
// Process child nodes recursively (increment depth)
|
|
const accessibleChildren: SidebarSubmenuItemData[] = [];
|
|
for (const childNode of node.children.values()) {
|
|
const childItem = await nodeToSubmenuItem(childNode, depth + 1);
|
|
if (childItem) {
|
|
accessibleChildren.push(childItem);
|
|
}
|
|
}
|
|
|
|
// Combine pages and child nodes, assigning depth
|
|
const allChildren: SidebarSubmenuItemData[] = [
|
|
...accessiblePages.map(page => ({
|
|
id: page.id,
|
|
name: resolveLanguageText(page.name, t),
|
|
link: `/${page.path}`,
|
|
icon: page.icon,
|
|
depth: depth + 1 // Child pages are one level deeper
|
|
})),
|
|
...accessibleChildren
|
|
];
|
|
|
|
// If no accessible children, don't create this node
|
|
if (allChildren.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
// If this node has a page itself, it shouldn't be a navigation node
|
|
// But according to requirements: if it has subpages, it is NOT a page itself
|
|
// So we create a navigation node without a link
|
|
return {
|
|
id: node.id,
|
|
name: node.name,
|
|
link: undefined, // Navigation node - not a clickable page
|
|
icon: node.icon,
|
|
submenu: allChildren.length > 0 ? allChildren : undefined,
|
|
depth: depth // Current depth level
|
|
};
|
|
};
|
|
|
|
// Convert navigation tree to sidebar items
|
|
const treeToSidebarItems = async (tree: Map<string, NavigationNode>): Promise<SidebarItem[]> => {
|
|
const items: SidebarItem[] = [];
|
|
|
|
// Process each root node (depth 0 for top-level items)
|
|
for (const node of tree.values()) {
|
|
const submenuItem = await nodeToSubmenuItem(node, 0);
|
|
if (submenuItem && submenuItem.submenu && submenuItem.submenu.length > 0) {
|
|
items.push({
|
|
id: node.id,
|
|
name: node.name,
|
|
link: undefined, // Navigation node - not a clickable page
|
|
icon: node.icon,
|
|
moduleEnabled: true,
|
|
order: node.order,
|
|
submenu: submenuItem.submenu,
|
|
depth: 0 // Top-level items have depth 0
|
|
});
|
|
}
|
|
}
|
|
|
|
return items;
|
|
};
|
|
|
|
// Get sidebar items from page data
|
|
const getSidebarItems = async (): Promise<SidebarItem[]> => {
|
|
const items: SidebarItem[] = [];
|
|
|
|
// Build navigation tree
|
|
const navigationTree = buildNavigationTree();
|
|
|
|
// Convert tree to sidebar items
|
|
const treeItems = await treeToSidebarItems(navigationTree);
|
|
items.push(...treeItems);
|
|
|
|
// Get main pages (no parent path)
|
|
const mainPages = allPageData
|
|
.filter(page => !page.parentPath && !page.hide && page.showInSidebar !== false)
|
|
.sort((a, b) => (a.order || 0) - (b.order || 0));
|
|
|
|
// Process each main page
|
|
for (const pageData of mainPages) {
|
|
// Check RBAC permissions
|
|
try {
|
|
const hasRBACAccess = await canView('UI', pageData.path);
|
|
if (!hasRBACAccess) {
|
|
continue;
|
|
}
|
|
|
|
// Check client-side privilegeChecker if provided
|
|
if (pageData.privilegeChecker) {
|
|
try {
|
|
const hasPrivilege = await pageData.privilegeChecker();
|
|
if (!hasPrivilege) {
|
|
continue;
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error checking privilegeChecker for ${pageData.path}:`, error);
|
|
continue;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error checking RBAC access for ${pageData.path}:`, error);
|
|
continue;
|
|
}
|
|
|
|
// Check if this page has subpages (legacy support)
|
|
if (pageData.hasSubpages) {
|
|
// Find all subpages for this parent
|
|
const allSubpages = allPageData.filter(p =>
|
|
p.parentPath === pageData.path &&
|
|
!p.hide &&
|
|
p.showInSidebar !== false
|
|
);
|
|
|
|
// Filter subpages by RBAC access
|
|
const accessibleSubpages: GenericPageData[] = [];
|
|
for (const subpage of allSubpages) {
|
|
try {
|
|
const hasSubpageRBACAccess = await canView('UI', subpage.path);
|
|
if (!hasSubpageRBACAccess) {
|
|
continue;
|
|
}
|
|
|
|
if (subpage.privilegeChecker) {
|
|
try {
|
|
const hasPrivilege = await subpage.privilegeChecker();
|
|
if (!hasPrivilege) {
|
|
continue;
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error checking privilegeChecker for subpage ${subpage.path}:`, error);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
accessibleSubpages.push(subpage);
|
|
} catch (error) {
|
|
console.error(`Error checking RBAC access for subpage ${subpage.path}:`, error);
|
|
}
|
|
}
|
|
|
|
if (accessibleSubpages.length > 0) {
|
|
// Create item with submenu (no link since it has subpages)
|
|
items.push({
|
|
id: pageData.id,
|
|
name: resolveLanguageText(pageData.name, t),
|
|
link: undefined, // No link - has subpages, so it's a navigation node
|
|
icon: pageData.icon,
|
|
moduleEnabled: pageData.moduleEnabled ?? true,
|
|
order: pageData.order || 0,
|
|
depth: 0, // Top-level items have depth 0
|
|
submenu: accessibleSubpages.map(subpage => ({
|
|
id: subpage.id,
|
|
name: resolveLanguageText(subpage.name, t),
|
|
link: `/${subpage.path}`,
|
|
icon: subpage.icon,
|
|
depth: 1 // First level of submenu
|
|
}))
|
|
});
|
|
} else {
|
|
// No accessible subpages, show as regular item
|
|
items.push({
|
|
id: pageData.id,
|
|
name: resolveLanguageText(pageData.name, t),
|
|
link: `/${pageData.path}`,
|
|
icon: pageData.icon,
|
|
moduleEnabled: pageData.moduleEnabled ?? true,
|
|
order: pageData.order || 0,
|
|
depth: 0 // Top-level items have depth 0
|
|
});
|
|
}
|
|
} else {
|
|
// Regular items without subpages
|
|
items.push({
|
|
id: pageData.id,
|
|
name: resolveLanguageText(pageData.name, t),
|
|
link: `/${pageData.path}`,
|
|
icon: pageData.icon,
|
|
moduleEnabled: pageData.moduleEnabled ?? true,
|
|
order: pageData.order || 0,
|
|
depth: 0 // Top-level items have depth 0
|
|
});
|
|
}
|
|
}
|
|
|
|
// Sort all items by order
|
|
const sortedItems = items.sort((a, b) => (a.order || 0) - (b.order || 0));
|
|
|
|
return sortedItems;
|
|
};
|
|
|
|
// Refresh sidebar items
|
|
const refreshSidebar = async () => {
|
|
console.log('🔄 SidebarProvider: Refreshing sidebar items...');
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
// Preload all UI permissions in a single API call
|
|
// This caches all permissions before iterating through pages
|
|
await preloadUiPermissions();
|
|
|
|
const items = await getSidebarItems();
|
|
console.log('✅ SidebarProvider: Setting sidebar items:', {
|
|
count: items.length,
|
|
items: items.map(item => ({ id: item.id, link: item.link, name: item.name }))
|
|
});
|
|
setSidebarItems(items);
|
|
} catch (err) {
|
|
console.error('❌ SidebarProvider: Error refreshing sidebar:', err);
|
|
setError(err instanceof Error ? err.message : 'Failed to load sidebar items');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// Load sidebar items on mount and when language changes
|
|
useEffect(() => {
|
|
refreshSidebar();
|
|
}, [t]);
|
|
|
|
const contextValue: SidebarContextType = {
|
|
sidebarItems,
|
|
loading,
|
|
error,
|
|
refreshSidebar
|
|
};
|
|
|
|
return (
|
|
<SidebarContext.Provider value={contextValue}>
|
|
{children}
|
|
</SidebarContext.Provider>
|
|
);
|
|
};
|
|
|
|
export default SidebarProvider;
|