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 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-03 13:05:05 -06:00
parent b0d5b94c07
commit 39cf33c3e5
7 changed files with 1251 additions and 0 deletions

View File

@ -16,3 +16,51 @@ export * from './useWhatsApp';
export * from './usePortfolio'; export * from './usePortfolio';
export * from './useGoals'; export * from './useGoals';
export * from './useMlm'; 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';

199
src/hooks/useRbac.ts Normal file
View File

@ -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<string, string> = {
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<string, string> = {
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 };
}

View File

@ -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<string>;
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 (
<div>
<div
className={`flex items-center justify-between rounded-lg p-2 hover:bg-gray-50 dark:hover:bg-gray-700`}
style={{ paddingLeft: `${level * 24 + 8}px` }}
>
<div className="flex items-center gap-2">
{hasChildren ? (
<button
onClick={() => toggleExpand(category.id)}
className="rounded p-1 hover:bg-gray-200 dark:hover:bg-gray-600"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-gray-400" />
) : (
<ChevronRight className="h-4 w-4 text-gray-400" />
)}
</button>
) : (
<div className="w-6" />
)}
<FolderTree className="h-5 w-5 text-gray-400" />
<Link
to={`/dashboard/portfolio/categories/${category.id}`}
className="text-sm font-medium text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400"
>
{category.name}
</Link>
{category.is_active === false && (
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500 dark:bg-gray-700 dark:text-gray-400">
Inactive
</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500 dark:text-gray-400">
{category.product_count || 0} products
</span>
<Link
to={`/dashboard/portfolio/categories/${category.id}/edit`}
className="rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-600 dark:hover:bg-gray-600 dark:hover:text-gray-300"
>
<Edit className="h-4 w-4" />
</Link>
<button
onClick={() => onDelete(category.id)}
className="rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-red-600 dark:hover:bg-gray-600 dark:hover:text-red-400"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
{hasChildren && isExpanded && (
<div>
{category.children.map((child: any) => (
<CategoryTreeNode
key={child.id}
category={child}
level={level + 1}
expandedIds={expandedIds}
toggleExpand={toggleExpand}
onDelete={onDelete}
/>
))}
</div>
)}
</div>
);
}
export default function CategoriesPage() {
const [searchTerm, setSearchTerm] = useState('');
const [expandedIds, setExpandedIds] = useState<Set<string>>(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 (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Categories</h1>
<p className="text-gray-500 dark:text-gray-400">
Organize your products into categories
</p>
</div>
<Link
to="/dashboard/portfolio/categories/new"
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
<Plus className="h-4 w-4" />
New Category
</Link>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search categories..."
value={searchTerm}
onChange={(e) => 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"
/>
</div>
{/* Category Tree */}
<div className="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<div className="border-b border-gray-200 p-4 dark:border-gray-700">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Category Tree</h2>
<button
onClick={() => {
if (expandedIds.size > 0) {
setExpandedIds(new Set());
} else {
const allIds = new Set<string>();
const collectIds = (cats: any[]) => {
cats.forEach(cat => {
if (cat.children?.length) {
allIds.add(cat.id);
collectIds(cat.children);
}
});
};
if (categoryTree) collectIds(categoryTree);
setExpandedIds(allIds);
}
}}
className="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
{expandedIds.size > 0 ? 'Collapse all' : 'Expand all'}
</button>
</div>
</div>
<div className="p-4">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent" />
</div>
) : filteredCategories.length ? (
<div className="space-y-1">
{filteredCategories.map((category: any) => (
<CategoryTreeNode
key={category.id}
category={category}
level={0}
expandedIds={expandedIds}
toggleExpand={toggleExpand}
onDelete={handleDelete}
/>
))}
</div>
) : (
<div className="py-12 text-center">
<FolderTree className="mx-auto h-12 w-12 text-gray-300 dark:text-gray-600" />
<p className="mt-4 text-gray-500 dark:text-gray-400">
{searchTerm ? 'No categories match your search.' : 'No categories yet. Create your first category.'}
</p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -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 (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Portfolio</h1>
<p className="text-gray-500 dark:text-gray-400">
Manage your product catalog and categories
</p>
</div>
<div className="flex gap-2">
<Link
to="/dashboard/portfolio/categories/new"
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
<FolderTree className="h-4 w-4" />
New Category
</Link>
<Link
to="/dashboard/portfolio/products/new"
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
<Plus className="h-4 w-4" />
New Product
</Link>
</div>
</div>
{/* Search */}
<div className="flex gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search products or categories..."
value={searchTerm}
onChange={(e) => 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"
/>
</div>
<button className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700">
<Filter className="h-4 w-4" />
Filters
</button>
</div>
{/* Stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-center gap-4">
<div className="rounded-lg bg-blue-100 p-3 dark:bg-blue-900/20">
<Package className="h-6 w-6 text-blue-600 dark:text-blue-400" />
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Total Products</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{products?.total || 0}
</p>
</div>
</div>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-center gap-4">
<div className="rounded-lg bg-green-100 p-3 dark:bg-green-900/20">
<FolderTree className="h-6 w-6 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Categories</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{categories?.total || 0}
</p>
</div>
</div>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-center gap-4">
<div className="rounded-lg bg-purple-100 p-3 dark:bg-purple-900/20">
<Package className="h-6 w-6 text-purple-600 dark:text-purple-400" />
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Active Products</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{products?.items?.filter((p: any) => p.status === 'active').length || 0}
</p>
</div>
</div>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-center gap-4">
<div className="rounded-lg bg-orange-100 p-3 dark:bg-orange-900/20">
<Package className="h-6 w-6 text-orange-600 dark:text-orange-400" />
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Draft Products</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{products?.items?.filter((p: any) => p.status === 'draft').length || 0}
</p>
</div>
</div>
</div>
</div>
{/* Quick Links */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Categories Section */}
<div className="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-center justify-between border-b border-gray-200 p-4 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Categories</h2>
<Link
to="/dashboard/portfolio/categories"
className="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
View all
</Link>
</div>
<div className="p-4">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent" />
</div>
) : categories?.items?.length ? (
<ul className="space-y-2">
{categories.items.slice(0, 5).map((category: any) => (
<li key={category.id}>
<Link
to={`/dashboard/portfolio/categories/${category.id}`}
className="flex items-center gap-3 rounded-lg p-2 hover:bg-gray-50 dark:hover:bg-gray-700"
>
<FolderTree className="h-5 w-5 text-gray-400" />
<span className="text-sm text-gray-900 dark:text-white">{category.name}</span>
</Link>
</li>
))}
</ul>
) : (
<p className="py-8 text-center text-gray-500 dark:text-gray-400">
No categories yet. Create your first category.
</p>
)}
</div>
</div>
{/* Recent Products Section */}
<div className="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-center justify-between border-b border-gray-200 p-4 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Recent Products</h2>
<Link
to="/dashboard/portfolio/products"
className="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
View all
</Link>
</div>
<div className="p-4">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent" />
</div>
) : products?.items?.length ? (
<ul className="space-y-2">
{products.items.slice(0, 5).map((product: any) => (
<li key={product.id}>
<Link
to={`/dashboard/portfolio/products/${product.id}`}
className="flex items-center justify-between rounded-lg p-2 hover:bg-gray-50 dark:hover:bg-gray-700"
>
<div className="flex items-center gap-3">
<Package className="h-5 w-5 text-gray-400" />
<span className="text-sm text-gray-900 dark:text-white">{product.name}</span>
</div>
<span className={`rounded-full px-2 py-1 text-xs ${
product.status === 'active'
? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400'
}`}>
{product.status}
</span>
</Link>
</li>
))}
</ul>
) : (
<p className="py-8 text-center text-gray-500 dark:text-gray-400">
No products yet. Create your first product.
</p>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -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<ProductStatus | 'all'>('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 (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Products</h1>
<p className="text-gray-500 dark:text-gray-400">
Manage your product catalog
</p>
</div>
<Link
to="/dashboard/portfolio/products/new"
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
<Plus className="h-4 w-4" />
New Product
</Link>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-4">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search products..."
value={searchTerm}
onChange={(e) => 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"
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as ProductStatus | 'all')}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 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"
>
<option value="all">All Status</option>
<option value="draft">Draft</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="discontinued">Discontinued</option>
<option value="out_of_stock">Out of Stock</option>
</select>
</div>
{/* Products Table */}
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Product
</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
SKU
</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Category
</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Price
</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Status
</th>
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{isLoading ? (
<tr>
<td colSpan={6} className="px-6 py-12 text-center">
<div className="flex items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent" />
</div>
</td>
</tr>
) : products?.items?.length ? (
products.items.map((product: any) => (
<tr key={product.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="whitespace-nowrap px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100 dark:bg-gray-700">
<Package className="h-5 w-5 text-gray-400" />
</div>
<div>
<p className="font-medium text-gray-900 dark:text-white">{product.name}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">{product.type}</p>
</div>
</div>
</td>
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{product.sku || '-'}
</td>
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{product.category?.name || '-'}
</td>
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-900 dark:text-white">
${product.base_price?.toFixed(2) || '0.00'}
</td>
<td className="whitespace-nowrap px-6 py-4">
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${
product.status === 'active'
? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400'
: product.status === 'draft'
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400'
}`}>
{product.status}
</span>
</td>
<td className="whitespace-nowrap px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<Link
to={`/dashboard/portfolio/products/${product.id}`}
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
title="View"
>
<Eye className="h-4 w-4" />
</Link>
<Link
to={`/dashboard/portfolio/products/${product.id}/edit`}
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
title="Edit"
>
<Edit className="h-4 w-4" />
</Link>
<button
onClick={() => handleDuplicate(product.id)}
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
title="Duplicate"
>
<Copy className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(product.id)}
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-red-600 dark:hover:bg-gray-700 dark:hover:text-red-400"
title="Delete"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))
) : (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
No products found. Create your first product.
</td>
</tr>
)}
</tbody>
</table>
{/* Pagination */}
{products && products.totalPages > 1 && (
<div className="flex items-center justify-between border-t border-gray-200 bg-white px-6 py-3 dark:border-gray-700 dark:bg-gray-800">
<p className="text-sm text-gray-500 dark:text-gray-400">
Showing {((page - 1) * 20) + 1} to {Math.min(page * 20, products.total)} of {products.total} products
</p>
<div className="flex gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="rounded-lg border border-gray-300 bg-white px-3 py-1 text-sm disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
Previous
</button>
<button
onClick={() => setPage(p => Math.min(products.totalPages, p + 1))}
disabled={page === products.totalPages}
className="rounded-lg border border-gray-300 bg-white px-3 py-1 text-sm disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
Next
</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -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<string | null>(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 (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Roles & Permissions</h1>
<p className="text-gray-500 dark:text-gray-400">
Manage user roles and their permissions
</p>
</div>
<Link
to="/dashboard/rbac/roles/new"
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
<Plus className="h-4 w-4" />
New Role
</Link>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Roles List */}
<div className="lg:col-span-1">
<div className="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<div className="border-b border-gray-200 p-4 dark:border-gray-700">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search roles..."
value={searchTerm}
onChange={(e) => 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"
/>
</div>
</div>
<div className="max-h-[600px] overflow-y-auto p-4">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent" />
</div>
) : filteredRoles?.length ? (
<div className="space-y-2">
{filteredRoles.map((role: any) => (
<button
key={role.id}
onClick={() => setSelectedRole(role.id)}
className={`w-full rounded-lg p-3 text-left transition-colors ${
selectedRole === role.id
? 'bg-blue-50 ring-2 ring-blue-500 dark:bg-blue-900/20'
: 'hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`rounded-lg p-2 ${
role.is_system
? 'bg-purple-100 dark:bg-purple-900/20'
: 'bg-gray-100 dark:bg-gray-700'
}`}>
<Shield className={`h-5 w-5 ${
role.is_system
? 'text-purple-600 dark:text-purple-400'
: 'text-gray-600 dark:text-gray-400'
}`} />
</div>
<div>
<p className="font-medium text-gray-900 dark:text-white">{role.name}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{role.permissions?.length || 0} permissions
</p>
</div>
</div>
{role.is_system && (
<Lock className="h-4 w-4 text-gray-400" />
)}
</div>
</button>
))}
</div>
) : (
<p className="py-8 text-center text-gray-500 dark:text-gray-400">
No roles found.
</p>
)}
</div>
</div>
</div>
{/* Role Details */}
<div className="lg:col-span-2">
{selectedRoleData ? (
<div className="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-center justify-between border-b border-gray-200 p-4 dark:border-gray-700">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{selectedRoleData.name}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{selectedRoleData.description || 'No description'}
</p>
</div>
<div className="flex gap-2">
{!selectedRoleData.is_system && (
<>
<Link
to={`/dashboard/rbac/roles/${selectedRoleData.id}/edit`}
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
<Edit className="h-4 w-4" />
Edit
</Link>
<button
onClick={() => handleDelete(selectedRoleData.id, selectedRoleData.is_system)}
className="inline-flex items-center gap-2 rounded-lg border border-red-300 bg-white px-3 py-2 text-sm font-medium text-red-700 hover:bg-red-50 dark:border-red-600 dark:bg-gray-800 dark:text-red-400 dark:hover:bg-red-900/20"
>
<Trash2 className="h-4 w-4" />
Delete
</button>
</>
)}
</div>
</div>
{/* Role Info */}
<div className="border-b border-gray-200 p-4 dark:border-gray-700">
<div className="grid grid-cols-3 gap-4">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Type</p>
<p className="font-medium text-gray-900 dark:text-white">
{selectedRoleData.is_system ? 'System' : 'Custom'}
</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Default</p>
<p className="font-medium text-gray-900 dark:text-white">
{selectedRoleData.is_default ? 'Yes' : 'No'}
</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Slug</p>
<p className="font-mono text-sm text-gray-900 dark:text-white">
{selectedRoleData.slug}
</p>
</div>
</div>
</div>
{/* Permissions */}
<div className="p-4">
<h3 className="mb-4 font-medium text-gray-900 dark:text-white">Permissions</h3>
{permissionsByCategory && Object.keys(permissionsByCategory).length > 0 ? (
<div className="space-y-4">
{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 (
<div key={category}>
<h4 className="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
{getPermissionCategoryLabel(category)}
</h4>
<div className="flex flex-wrap gap-2">
{categoryPerms.map((perm: any) => (
<span
key={perm.id}
className="inline-flex items-center rounded-full bg-blue-100 px-3 py-1 text-xs font-medium text-blue-800 dark:bg-blue-900/20 dark:text-blue-400"
>
{perm.name}
</span>
))}
</div>
</div>
);
})}
</div>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">
No permissions assigned to this role.
</p>
)}
</div>
</div>
) : (
<div className="flex h-full items-center justify-center rounded-lg border border-gray-200 bg-white p-12 dark:border-gray-700 dark:bg-gray-800">
<div className="text-center">
<Shield className="mx-auto h-12 w-12 text-gray-300 dark:text-gray-600" />
<p className="mt-4 text-gray-500 dark:text-gray-400">
Select a role to view its details and permissions
</p>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -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<Role[]> => {
const response = await api.get<Role[]>('/rbac/roles');
return response.data;
},
getRole: async (id: string): Promise<Role> => {
const response = await api.get<Role>(`/rbac/roles/${id}`);
return response.data;
},
createRole: async (data: CreateRoleRequest): Promise<Role> => {
const response = await api.post<Role>('/rbac/roles', data);
return response.data;
},
updateRole: async (id: string, data: UpdateRoleRequest): Promise<Role> => {
const response = await api.patch<Role>(`/rbac/roles/${id}`, data);
return response.data;
},
deleteRole: async (id: string): Promise<void> => {
await api.delete(`/rbac/roles/${id}`);
},
// Permissions
listPermissions: async (): Promise<Permission[]> => {
const response = await api.get<Permission[]>('/rbac/permissions');
return response.data;
},
getPermissionsByCategory: async (category: string): Promise<Permission[]> => {
const response = await api.get<Permission[]>(`/rbac/permissions/category/${category}`);
return response.data;
},
// User Roles
getUserRoles: async (userId: string): Promise<UserRole[]> => {
const response = await api.get<UserRole[]>(`/rbac/users/${userId}/roles`);
return response.data;
},
getUserPermissions: async (userId: string): Promise<string[]> => {
const response = await api.get<string[]>(`/rbac/users/${userId}/permissions`);
return response.data;
},
assignRoleToUser: async (data: AssignRoleRequest): Promise<UserRole> => {
const response = await api.post<UserRole>('/rbac/users/assign-role', data);
return response.data;
},
removeRoleFromUser: async (userId: string, roleId: string): Promise<void> => {
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<Role[]> => {
const response = await api.get<Role[]>('/rbac/me/roles');
return response.data;
},
getMyPermissions: async (): Promise<string[]> => {
const response = await api.get<string[]>('/rbac/me/permissions');
return response.data;
},
};
// Export utilities // Export utilities
export { getTenantId }; export { getTenantId };
export default api; export default api;