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 './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
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 utilities
|
||||||
export { getTenantId };
|
export { getTenantId };
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user