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:
parent
b0d5b94c07
commit
39cf33c3e5
@ -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';
|
||||
|
||||
199
src/hooks/useRbac.ts
Normal file
199
src/hooks/useRbac.ts
Normal 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 };
|
||||
}
|
||||
217
src/pages/dashboard/portfolio/CategoriesPage.tsx
Normal file
217
src/pages/dashboard/portfolio/CategoriesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
204
src/pages/dashboard/portfolio/PortfolioPage.tsx
Normal file
204
src/pages/dashboard/portfolio/PortfolioPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
216
src/pages/dashboard/portfolio/ProductsPage.tsx
Normal file
216
src/pages/dashboard/portfolio/ProductsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
241
src/pages/dashboard/rbac/RolesPage.tsx
Normal file
241
src/pages/dashboard/rbac/RolesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 { getTenantId };
|
||||
export default api;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user