From 39cf33c3e5dffa390e182d4369feb06508bf022e Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Tue, 3 Feb 2026 13:05:05 -0600 Subject: [PATCH] feat(rbac,portfolio): Add RBAC hook and Portfolio/RBAC pages - Add useRbac hook with roles, permissions, and user role management - Add RBAC API endpoints to api.ts (roles, permissions, user assignments) - Add RolesPage for managing roles and viewing permissions - Add PortfolioPage dashboard with stats and quick links - Add ProductsPage with search, filters, and pagination - Add CategoriesPage with tree view and expand/collapse - Fix useMyEarnings export conflict between useMlm and useCommissions - Export useMyCommissionEarnings as alias to avoid naming conflict Co-Authored-By: Claude Opus 4.5 --- src/hooks/index.ts | 48 ++++ src/hooks/useRbac.ts | 199 +++++++++++++++ .../dashboard/portfolio/CategoriesPage.tsx | 217 ++++++++++++++++ .../dashboard/portfolio/PortfolioPage.tsx | 204 +++++++++++++++ .../dashboard/portfolio/ProductsPage.tsx | 216 ++++++++++++++++ src/pages/dashboard/rbac/RolesPage.tsx | 241 ++++++++++++++++++ src/services/api.ts | 126 +++++++++ 7 files changed, 1251 insertions(+) create mode 100644 src/hooks/useRbac.ts create mode 100644 src/pages/dashboard/portfolio/CategoriesPage.tsx create mode 100644 src/pages/dashboard/portfolio/PortfolioPage.tsx create mode 100644 src/pages/dashboard/portfolio/ProductsPage.tsx create mode 100644 src/pages/dashboard/rbac/RolesPage.tsx diff --git a/src/hooks/index.ts b/src/hooks/index.ts index e279389..ace76ef 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -16,3 +16,51 @@ export * from './useWhatsApp'; export * from './usePortfolio'; export * from './useGoals'; export * from './useMlm'; +export * from './useSales'; +// Note: useCommissions exports useMyEarnings which conflicts with useMlm +// Re-export all except useMyEarnings, use useMyMLMEarnings or useMyCommissionEarnings for disambiguation +export { + useSchemes, + useActiveSchemes, + useScheme, + useCreateScheme, + useUpdateScheme, + useDeleteScheme, + useDuplicateScheme, + useToggleSchemeActive, + useAssignments, + useAssignment, + useUserAssignments, + useUserActiveScheme, + useSchemeAssignees, + useCreateAssignment, + useUpdateAssignment, + useRemoveAssignment, + useDeactivateAssignment, + useEntries, + usePendingEntries, + useEntry, + useUserEntries, + usePeriodEntries, + useCalculateCommission, + useSimulateCommission, + useUpdateEntryStatus, + useBulkApproveEntries, + useBulkRejectEntries, + usePeriods, + useOpenPeriod, + usePeriod, + usePeriodSummary, + useCreatePeriod, + useClosePeriod, + useReopenPeriod, + useMarkPeriodPaid, + useCommissionsDashboard, + useEarningsByUser, + useEarningsByPeriod, + useTopEarners, + useMyEarnings as useMyCommissionEarnings, + useUserEarnings, + useSchemePerformance, +} from './useCommissions'; +export * from './useRbac'; diff --git a/src/hooks/useRbac.ts b/src/hooks/useRbac.ts new file mode 100644 index 0000000..6d87f6f --- /dev/null +++ b/src/hooks/useRbac.ts @@ -0,0 +1,199 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + rbacApi, + type CreateRoleRequest, + type UpdateRoleRequest, + type AssignRoleRequest, +} from '@/services/api'; + +// Query keys +const rbacKeys = { + all: ['rbac'] as const, + roles: () => [...rbacKeys.all, 'roles'] as const, + role: (id: string) => [...rbacKeys.roles(), id] as const, + permissions: () => [...rbacKeys.all, 'permissions'] as const, + permissionsByCategory: (category: string) => [...rbacKeys.permissions(), 'category', category] as const, + userRoles: (userId: string) => [...rbacKeys.all, 'user', userId, 'roles'] as const, + userPermissions: (userId: string) => [...rbacKeys.all, 'user', userId, 'permissions'] as const, + myRoles: () => [...rbacKeys.all, 'me', 'roles'] as const, + myPermissions: () => [...rbacKeys.all, 'me', 'permissions'] as const, +}; + +// ==================== Roles ==================== + +export function useRoles() { + return useQuery({ + queryKey: rbacKeys.roles(), + queryFn: () => rbacApi.listRoles(), + }); +} + +export function useRole(id: string) { + return useQuery({ + queryKey: rbacKeys.role(id), + queryFn: () => rbacApi.getRole(id), + enabled: !!id, + }); +} + +export function useCreateRole() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: CreateRoleRequest) => rbacApi.createRole(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: rbacKeys.roles() }); + }, + }); +} + +export function useUpdateRole() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: UpdateRoleRequest }) => + rbacApi.updateRole(id, data), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: rbacKeys.roles() }); + queryClient.invalidateQueries({ queryKey: rbacKeys.role(variables.id) }); + }, + }); +} + +export function useDeleteRole() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: string) => rbacApi.deleteRole(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: rbacKeys.roles() }); + }, + }); +} + +// ==================== Permissions ==================== + +export function usePermissions() { + return useQuery({ + queryKey: rbacKeys.permissions(), + queryFn: () => rbacApi.listPermissions(), + }); +} + +export function usePermissionsByCategory(category: string) { + return useQuery({ + queryKey: rbacKeys.permissionsByCategory(category), + queryFn: () => rbacApi.getPermissionsByCategory(category), + enabled: !!category, + }); +} + +// ==================== User Roles ==================== + +export function useUserRoles(userId: string) { + return useQuery({ + queryKey: rbacKeys.userRoles(userId), + queryFn: () => rbacApi.getUserRoles(userId), + enabled: !!userId, + }); +} + +export function useUserPermissions(userId: string) { + return useQuery({ + queryKey: rbacKeys.userPermissions(userId), + queryFn: () => rbacApi.getUserPermissions(userId), + enabled: !!userId, + }); +} + +export function useAssignRole() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: AssignRoleRequest) => rbacApi.assignRoleToUser(data), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: rbacKeys.userRoles(variables.user_id) }); + queryClient.invalidateQueries({ queryKey: rbacKeys.userPermissions(variables.user_id) }); + }, + }); +} + +export function useRemoveRole() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ userId, roleId }: { userId: string; roleId: string }) => + rbacApi.removeRoleFromUser(userId, roleId), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: rbacKeys.userRoles(variables.userId) }); + queryClient.invalidateQueries({ queryKey: rbacKeys.userPermissions(variables.userId) }); + }, + }); +} + +// ==================== Current User ==================== + +export function useMyRoles() { + return useQuery({ + queryKey: rbacKeys.myRoles(), + queryFn: () => rbacApi.getMyRoles(), + }); +} + +export function useMyPermissions() { + return useQuery({ + queryKey: rbacKeys.myPermissions(), + queryFn: () => rbacApi.getMyPermissions(), + }); +} + +export function useCheckPermission(permission: string) { + return useQuery({ + queryKey: [...rbacKeys.all, 'check', permission], + queryFn: () => rbacApi.checkPermission(permission), + enabled: !!permission, + }); +} + +// ==================== Helper functions ==================== + +export function getPermissionCategoryLabel(category: string): string { + const labels: Record = { + users: 'Users', + roles: 'Roles & Permissions', + billing: 'Billing', + tenants: 'Tenants', + audit: 'Audit Logs', + settings: 'Settings', + reports: 'Reports', + analytics: 'Analytics', + storage: 'Storage', + webhooks: 'Webhooks', + notifications: 'Notifications', + ai: 'AI Integration', + sales: 'Sales', + commissions: 'Commissions', + portfolio: 'Portfolio', + goals: 'Goals', + mlm: 'MLM', + }; + return labels[category] || category; +} + +export function getPermissionActionLabel(action: string): string { + const labels: Record = { + read: 'View', + write: 'Create/Edit', + delete: 'Delete', + assign: 'Assign', + manage: 'Manage', + export: 'Export', + import: 'Import', + }; + return labels[action] || action; +} + +export function parsePermissionSlug(slug: string): { category: string; action: string } { + const [category, action] = slug.split(':'); + return { category, action }; +} diff --git a/src/pages/dashboard/portfolio/CategoriesPage.tsx b/src/pages/dashboard/portfolio/CategoriesPage.tsx new file mode 100644 index 0000000..0e83151 --- /dev/null +++ b/src/pages/dashboard/portfolio/CategoriesPage.tsx @@ -0,0 +1,217 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { FolderTree, Plus, Search, Edit, Trash2, ChevronRight, ChevronDown } from 'lucide-react'; +import { useCategoryTree, useDeleteCategory } from '@/hooks/usePortfolio'; + +interface CategoryTreeNodeProps { + category: any; + level: number; + expandedIds: Set; + toggleExpand: (id: string) => void; + onDelete: (id: string) => void; +} + +function CategoryTreeNode({ category, level, expandedIds, toggleExpand, onDelete }: CategoryTreeNodeProps) { + const isExpanded = expandedIds.has(category.id); + const hasChildren = category.children && category.children.length > 0; + + return ( +
+
+
+ {hasChildren ? ( + + ) : ( +
+ )} + + + {category.name} + + {category.is_active === false && ( + + Inactive + + )} +
+
+ + {category.product_count || 0} products + + + + + +
+
+ {hasChildren && isExpanded && ( +
+ {category.children.map((child: any) => ( + + ))} +
+ )} +
+ ); +} + +export default function CategoriesPage() { + const [searchTerm, setSearchTerm] = useState(''); + const [expandedIds, setExpandedIds] = useState>(new Set()); + + const { data: categoryTree, isLoading } = useCategoryTree(); + const deleteMutation = useDeleteCategory(); + + const toggleExpand = (id: string) => { + setExpandedIds(prev => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + const handleDelete = async (id: string) => { + if (window.confirm('Are you sure you want to delete this category? Products in this category will be uncategorized.')) { + await deleteMutation.mutateAsync(id); + } + }; + + const filterCategories = (categories: any[], term: string): any[] => { + if (!term) return categories; + return categories.filter(cat => { + const matches = cat.name.toLowerCase().includes(term.toLowerCase()); + const childMatches = cat.children ? filterCategories(cat.children, term).length > 0 : false; + return matches || childMatches; + }).map(cat => ({ + ...cat, + children: cat.children ? filterCategories(cat.children, term) : [], + })); + }; + + const filteredCategories = categoryTree ? filterCategories(categoryTree, searchTerm) : []; + + return ( +
+ {/* Header */} +
+
+

Categories

+

+ Organize your products into categories +

+
+ + + New Category + +
+ + {/* Search */} +
+ + setSearchTerm(e.target.value)} + className="w-full rounded-lg border border-gray-300 bg-white py-2 pl-10 pr-4 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white" + /> +
+ + {/* Category Tree */} +
+
+
+

Category Tree

+ +
+
+
+ {isLoading ? ( +
+
+
+ ) : filteredCategories.length ? ( +
+ {filteredCategories.map((category: any) => ( + + ))} +
+ ) : ( +
+ +

+ {searchTerm ? 'No categories match your search.' : 'No categories yet. Create your first category.'} +

+
+ )} +
+
+
+ ); +} diff --git a/src/pages/dashboard/portfolio/PortfolioPage.tsx b/src/pages/dashboard/portfolio/PortfolioPage.tsx new file mode 100644 index 0000000..224e313 --- /dev/null +++ b/src/pages/dashboard/portfolio/PortfolioPage.tsx @@ -0,0 +1,204 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Package, FolderTree, Plus, Search, Filter } from 'lucide-react'; +import { useCategories, useProducts } from '@/hooks/usePortfolio'; + +export default function PortfolioPage() { + const [searchTerm, setSearchTerm] = useState(''); + const { data: categories, isLoading: loadingCategories } = useCategories(); + const { data: products, isLoading: loadingProducts } = useProducts({ limit: 10 }); + + const isLoading = loadingCategories || loadingProducts; + + return ( +
+ {/* Header */} +
+
+

Portfolio

+

+ Manage your product catalog and categories +

+
+
+ + + New Category + + + + New Product + +
+
+ + {/* Search */} +
+
+ + setSearchTerm(e.target.value)} + className="w-full rounded-lg border border-gray-300 bg-white py-2 pl-10 pr-4 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white" + /> +
+ +
+ + {/* Stats */} +
+
+
+
+ +
+
+

Total Products

+

+ {products?.total || 0} +

+
+
+
+
+
+
+ +
+
+

Categories

+

+ {categories?.total || 0} +

+
+
+
+
+
+
+ +
+
+

Active Products

+

+ {products?.items?.filter((p: any) => p.status === 'active').length || 0} +

+
+
+
+
+
+
+ +
+
+

Draft Products

+

+ {products?.items?.filter((p: any) => p.status === 'draft').length || 0} +

+
+
+
+
+ + {/* Quick Links */} +
+ {/* Categories Section */} +
+
+

Categories

+ + View all + +
+
+ {isLoading ? ( +
+
+
+ ) : categories?.items?.length ? ( +
    + {categories.items.slice(0, 5).map((category: any) => ( +
  • + + + {category.name} + +
  • + ))} +
+ ) : ( +

+ No categories yet. Create your first category. +

+ )} +
+
+ + {/* Recent Products Section */} +
+
+

Recent Products

+ + View all + +
+
+ {isLoading ? ( +
+
+
+ ) : products?.items?.length ? ( +
    + {products.items.slice(0, 5).map((product: any) => ( +
  • + +
    + + {product.name} +
    + + {product.status} + + +
  • + ))} +
+ ) : ( +

+ No products yet. Create your first product. +

+ )} +
+
+
+
+ ); +} diff --git a/src/pages/dashboard/portfolio/ProductsPage.tsx b/src/pages/dashboard/portfolio/ProductsPage.tsx new file mode 100644 index 0000000..e6a2f38 --- /dev/null +++ b/src/pages/dashboard/portfolio/ProductsPage.tsx @@ -0,0 +1,216 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Package, Plus, Search, Edit, Trash2, Eye, Copy } from 'lucide-react'; +import { useProducts, useDeleteProduct, useDuplicateProduct } from '@/hooks/usePortfolio'; +import type { ProductStatus } from '@/services/portfolio'; + +export default function ProductsPage() { + const [searchTerm, setSearchTerm] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [page, setPage] = useState(1); + + const { data: products, isLoading } = useProducts({ + page, + limit: 20, + search: searchTerm || undefined, + status: statusFilter !== 'all' ? statusFilter : undefined, + }); + + const deleteMutation = useDeleteProduct(); + const duplicateMutation = useDuplicateProduct(); + + const handleDelete = async (id: string) => { + if (window.confirm('Are you sure you want to delete this product?')) { + await deleteMutation.mutateAsync(id); + } + }; + + const handleDuplicate = async (id: string) => { + await duplicateMutation.mutateAsync(id); + }; + + return ( +
+ {/* Header */} +
+
+

Products

+

+ Manage your product catalog +

+
+ + + New Product + +
+ + {/* Filters */} +
+
+ + setSearchTerm(e.target.value)} + className="w-full rounded-lg border border-gray-300 bg-white py-2 pl-10 pr-4 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white" + /> +
+ +
+ + {/* Products Table */} +
+ + + + + + + + + + + + + {isLoading ? ( + + + + ) : products?.items?.length ? ( + products.items.map((product: any) => ( + + + + + + + + + )) + ) : ( + + + + )} + +
+ Product + + SKU + + Category + + Price + + Status + + Actions +
+
+
+
+
+
+
+ +
+
+

{product.name}

+

{product.type}

+
+
+
+ {product.sku || '-'} + + {product.category?.name || '-'} + + ${product.base_price?.toFixed(2) || '0.00'} + + + {product.status} + + +
+ + + + + + + + +
+
+ No products found. Create your first product. +
+ + {/* Pagination */} + {products && products.totalPages > 1 && ( +
+

+ Showing {((page - 1) * 20) + 1} to {Math.min(page * 20, products.total)} of {products.total} products +

+
+ + +
+
+ )} +
+
+ ); +} diff --git a/src/pages/dashboard/rbac/RolesPage.tsx b/src/pages/dashboard/rbac/RolesPage.tsx new file mode 100644 index 0000000..bd209ff --- /dev/null +++ b/src/pages/dashboard/rbac/RolesPage.tsx @@ -0,0 +1,241 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Shield, Plus, Search, Edit, Trash2, Lock } from 'lucide-react'; +import { + useRoles, + useDeleteRole, + usePermissions, + getPermissionCategoryLabel, +} from '@/hooks/useRbac'; + +export default function RolesPage() { + const [searchTerm, setSearchTerm] = useState(''); + const [selectedRole, setSelectedRole] = useState(null); + + const { data: roles, isLoading } = useRoles(); + const { data: permissions } = usePermissions(); + const deleteMutation = useDeleteRole(); + + const handleDelete = async (id: string, isSystem: boolean) => { + if (isSystem) { + alert('System roles cannot be deleted.'); + return; + } + if (window.confirm('Are you sure you want to delete this role?')) { + await deleteMutation.mutateAsync(id); + } + }; + + const filteredRoles = roles?.filter((role: any) => + role.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + const selectedRoleData = roles?.find((r: any) => r.id === selectedRole); + + // Group permissions by category + const permissionsByCategory = permissions?.reduce((acc: any, perm: any) => { + if (!acc[perm.category]) { + acc[perm.category] = []; + } + acc[perm.category].push(perm); + return acc; + }, {}); + + return ( +
+ {/* Header */} +
+
+

Roles & Permissions

+

+ Manage user roles and their permissions +

+
+ + + New Role + +
+ +
+ {/* Roles List */} +
+
+
+
+ + setSearchTerm(e.target.value)} + className="w-full rounded-lg border border-gray-300 bg-white py-2 pl-10 pr-4 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white" + /> +
+
+
+ {isLoading ? ( +
+
+
+ ) : filteredRoles?.length ? ( +
+ {filteredRoles.map((role: any) => ( + + ))} +
+ ) : ( +

+ No roles found. +

+ )} +
+
+
+ + {/* Role Details */} +
+ {selectedRoleData ? ( +
+
+
+

+ {selectedRoleData.name} +

+

+ {selectedRoleData.description || 'No description'} +

+
+
+ {!selectedRoleData.is_system && ( + <> + + + Edit + + + + )} +
+
+ + {/* Role Info */} +
+
+
+

Type

+

+ {selectedRoleData.is_system ? 'System' : 'Custom'} +

+
+
+

Default

+

+ {selectedRoleData.is_default ? 'Yes' : 'No'} +

+
+
+

Slug

+

+ {selectedRoleData.slug} +

+
+
+
+ + {/* Permissions */} +
+

Permissions

+ {permissionsByCategory && Object.keys(permissionsByCategory).length > 0 ? ( +
+ {Object.entries(permissionsByCategory).map(([category, perms]: [string, any]) => { + const rolePermSlugs = selectedRoleData.permissions?.map((p: any) => p.slug) || []; + const categoryPerms = perms.filter((p: any) => rolePermSlugs.includes(p.slug)); + if (categoryPerms.length === 0) return null; + + return ( +
+

+ {getPermissionCategoryLabel(category)} +

+
+ {categoryPerms.map((perm: any) => ( + + {perm.name} + + ))} +
+
+ ); + })} +
+ ) : ( +

+ No permissions assigned to this role. +

+ )} +
+
+ ) : ( +
+
+ +

+ Select a role to view its details and permissions +

+
+
+ )} +
+
+
+ ); +} diff --git a/src/services/api.ts b/src/services/api.ts index db6ceff..5c7fde6 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1338,6 +1338,132 @@ export const analyticsApi = { }, }; +// RBAC API +export interface Role { + id: string; + tenant_id: string; + name: string; + slug: string; + description: string | null; + is_system: boolean; + is_default: boolean; + permissions?: Permission[]; + created_at: string; + updated_at: string; +} + +export interface Permission { + id: string; + name: string; + slug: string; + description: string | null; + category: string; + created_at: string; +} + +export interface UserRole { + id: string; + user_id: string; + role_id: string; + role?: Role; + assigned_by: string | null; + assigned_at: string; +} + +export interface CreateRoleRequest { + name: string; + slug?: string; + description?: string; + permissions?: string[]; +} + +export interface UpdateRoleRequest { + name?: string; + description?: string; + permissions?: string[]; +} + +export interface AssignRoleRequest { + user_id: string; + role_id: string; +} + +export const rbacApi = { + // Roles + listRoles: async (): Promise => { + const response = await api.get('/rbac/roles'); + return response.data; + }, + + getRole: async (id: string): Promise => { + const response = await api.get(`/rbac/roles/${id}`); + return response.data; + }, + + createRole: async (data: CreateRoleRequest): Promise => { + const response = await api.post('/rbac/roles', data); + return response.data; + }, + + updateRole: async (id: string, data: UpdateRoleRequest): Promise => { + const response = await api.patch(`/rbac/roles/${id}`, data); + return response.data; + }, + + deleteRole: async (id: string): Promise => { + await api.delete(`/rbac/roles/${id}`); + }, + + // Permissions + listPermissions: async (): Promise => { + const response = await api.get('/rbac/permissions'); + return response.data; + }, + + getPermissionsByCategory: async (category: string): Promise => { + const response = await api.get(`/rbac/permissions/category/${category}`); + return response.data; + }, + + // User Roles + getUserRoles: async (userId: string): Promise => { + const response = await api.get(`/rbac/users/${userId}/roles`); + return response.data; + }, + + getUserPermissions: async (userId: string): Promise => { + const response = await api.get(`/rbac/users/${userId}/permissions`); + return response.data; + }, + + assignRoleToUser: async (data: AssignRoleRequest): Promise => { + const response = await api.post('/rbac/users/assign-role', data); + return response.data; + }, + + removeRoleFromUser: async (userId: string, roleId: string): Promise => { + await api.delete(`/rbac/users/${userId}/roles/${roleId}`); + }, + + // Permission Check + checkPermission: async (permission: string): Promise<{ hasPermission: boolean }> => { + const response = await api.get<{ hasPermission: boolean }>('/rbac/check', { + params: { permission }, + }); + return response.data; + }, + + getMyRoles: async (): Promise => { + const response = await api.get('/rbac/me/roles'); + return response.data; + }, + + getMyPermissions: async (): Promise => { + const response = await api.get('/rbac/me/permissions'); + return response.data; + }, +}; + // Export utilities export { getTenantId }; export default api;