Refactor Layout and Sidebar components for improved responsiveness and permission handling. Update layout styles for better spacing and adjust Sidebar menu items to include permission checks for tenant users. Remove unused EditTenantModal and NewModuleModal components to streamline shared components.
This commit is contained in:
parent
0ed7bc5064
commit
55b0d9c8c1
@ -32,7 +32,7 @@ export const Layout = ({ children, currentPage, breadcrumbs, pageHeader }: Layou
|
||||
<div className="absolute top-0 left-[-80px] w-full md:left-[-80px] md:w-[1440px] h-full bg-[#f6f9ff] z-0" />
|
||||
|
||||
{/* Content Wrapper */}
|
||||
<div className="absolute inset-0 flex gap-0 md:gap-3 p-0 md:p-3 max-w-full md:max-w-[1280px] lg:max-w-none h-full mx-auto lg:mx-0 z-10">
|
||||
<div className="absolute inset-0 flex gap-0 md:gap-2 lg:gap-3 p-0 md:p-2 lg:p-3 max-w-full md:max-w-full lg:max-w-[1280px] xl:max-w-none h-full mx-auto lg:mx-auto xl:mx-0 z-10">
|
||||
{/* Mobile Overlay */}
|
||||
{isSidebarOpen && (
|
||||
<div
|
||||
@ -46,12 +46,12 @@ export const Layout = ({ children, currentPage, breadcrumbs, pageHeader }: Layou
|
||||
<Sidebar isOpen={isSidebarOpen} onClose={closeSidebar} />
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 min-w-0 min-h-0 bg-white border-0 md:border border-[rgba(0,0,0,0.08)] rounded-none md:rounded-xl shadow-none md:shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] flex flex-col overflow-hidden w-full">
|
||||
<main className="flex-1 min-w-0 min-h-0 max-w-full bg-white border-0 md:border border-[rgba(0,0,0,0.08)] rounded-none md:rounded-xl shadow-none md:shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] flex flex-col overflow-hidden">
|
||||
{/* Top Header */}
|
||||
<Header currentPage={currentPage} breadcrumbs={breadcrumbs} onMenuClick={toggleSidebar} />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 min-h-0 p-4 md:p-6 overflow-y-auto relative z-0">
|
||||
<div className="flex-1 min-h-0 p-4 md:p-4 lg:p-6 overflow-y-auto relative z-0">
|
||||
{/* Page Header */}
|
||||
{pageHeader && (
|
||||
<PageHeader
|
||||
|
||||
@ -18,6 +18,10 @@ interface MenuItem {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
path: string;
|
||||
requiredPermission?: {
|
||||
resource: string;
|
||||
action?: string; // If not provided, checks for '*' or 'read'
|
||||
};
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
@ -29,32 +33,32 @@ interface SidebarProps {
|
||||
const superAdminPlatformMenu: MenuItem[] = [
|
||||
{ icon: LayoutDashboard, label: 'Dashboard', path: '/dashboard' },
|
||||
{ icon: Building2, label: 'Tenants', path: '/tenants' },
|
||||
{ icon: Users, label: 'User Management', path: '/users' },
|
||||
{ icon: Shield, label: 'Roles', path: '/roles' },
|
||||
// { icon: Users, label: 'User Management', path: '/users' },
|
||||
// { icon: Shield, label: 'Roles', path: '/roles' },
|
||||
{ icon: Package, label: 'Modules', path: '/modules' },
|
||||
];
|
||||
|
||||
const superAdminSystemMenu: MenuItem[] = [
|
||||
{ icon: FileText, label: 'Audit Logs', path: '/audit-logs' },
|
||||
{ icon: Settings, label: 'Settings', path: '/settings' },
|
||||
// { icon: Settings, label: 'Settings', path: '/settings' },
|
||||
];
|
||||
|
||||
// Tenant Admin menu items
|
||||
const tenantAdminPlatformMenu: MenuItem[] = [
|
||||
{ icon: LayoutDashboard, label: 'Dashboard', path: '/tenant' },
|
||||
{ icon: Shield, label: 'Roles', path: '/tenant/roles' },
|
||||
{ icon: Users, label: 'Users', path: '/tenant/users' },
|
||||
{ icon: Package, label: 'Modules', path: '/tenant/modules' },
|
||||
{ icon: Shield, label: 'Roles', path: '/tenant/roles', requiredPermission: { resource: 'roles' } },
|
||||
{ icon: Users, label: 'Users', path: '/tenant/users', requiredPermission: { resource: 'users' } },
|
||||
{ icon: Package, label: 'TenantModules', path: '/tenant/modules', requiredPermission: { resource: 'tenants' } },
|
||||
];
|
||||
|
||||
const tenantAdminSystemMenu: MenuItem[] = [
|
||||
{ icon: FileText, label: 'Audit Logs', path: '/tenant/audit-logs' },
|
||||
{ icon: Settings, label: 'Settings', path: '/tenant/settings' },
|
||||
{ icon: FileText, label: 'Audit Logs', path: '/tenant/audit-logs', requiredPermission: { resource: 'audit_logs' } },
|
||||
{ icon: Settings, label: 'Settings', path: '/tenant/settings', requiredPermission: { resource: 'tenants' } },
|
||||
];
|
||||
|
||||
export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||
const location = useLocation();
|
||||
const { roles } = useAppSelector((state) => state.auth);
|
||||
const { roles, permissions } = useAppSelector((state) => state.auth);
|
||||
const { theme, logoUrl } = useAppSelector((state) => state.theme);
|
||||
|
||||
// Fetch theme for tenant admin
|
||||
@ -79,12 +83,56 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||
useTenantTheme();
|
||||
}
|
||||
|
||||
// Select menu items based on role
|
||||
const platformMenu = isSuperAdmin ? superAdminPlatformMenu : tenantAdminPlatformMenu;
|
||||
const systemMenu = isSuperAdmin ? superAdminSystemMenu : tenantAdminSystemMenu;
|
||||
// Helper function to check if user has permission for a resource
|
||||
const hasPermission = (resource: string, requiredAction?: string): boolean => {
|
||||
if (isSuperAdmin) {
|
||||
return true; // Super admin has all permissions
|
||||
}
|
||||
|
||||
const allowedActions = requiredAction ? [requiredAction] : ['*', 'read'];
|
||||
|
||||
return permissions.some((perm) => {
|
||||
// Check if resource matches (exact match or wildcard)
|
||||
const resourceMatches = perm.resource === resource || perm.resource === '*';
|
||||
|
||||
// Check if action matches (exact match or wildcard)
|
||||
const actionMatches = allowedActions.some(
|
||||
(allowedAction) => perm.action === allowedAction || perm.action === '*'
|
||||
);
|
||||
|
||||
return resourceMatches && actionMatches;
|
||||
});
|
||||
};
|
||||
|
||||
// Filter menu items based on permissions for tenant users
|
||||
const filterMenuItems = (items: MenuItem[]): MenuItem[] => {
|
||||
if (isSuperAdmin) {
|
||||
return items; // Show all items for super admin
|
||||
}
|
||||
|
||||
return items.filter((item) => {
|
||||
// If no required permission, always show (e.g., Dashboard, Modules, Settings)
|
||||
if (!item.requiredPermission) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return hasPermission(
|
||||
item.requiredPermission.resource,
|
||||
item.requiredPermission.action
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Select and filter menu items based on role and permissions
|
||||
const platformMenu = filterMenuItems(
|
||||
isSuperAdmin ? superAdminPlatformMenu : tenantAdminPlatformMenu
|
||||
);
|
||||
const systemMenu = filterMenuItems(
|
||||
isSuperAdmin ? superAdminSystemMenu : tenantAdminSystemMenu
|
||||
);
|
||||
|
||||
const MenuSection = ({ title, items }: { title: string; items: MenuItem[] }) => (
|
||||
<div className="w-full md:w-[206px]">
|
||||
<div className="w-full">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="pb-1 px-3">
|
||||
<div className="text-[11px] font-semibold text-[#6b7280] uppercase tracking-[0.88px]">
|
||||
@ -193,9 +241,9 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||
</aside>
|
||||
|
||||
{/* Desktop Sidebar */}
|
||||
<aside className="hidden md:flex bg-white border border-[rgba(0,0,0,0.08)] rounded-xl p-[17px] w-[240px] h-full max-h-screen flex-col gap-6 shrink-0 overflow-hidden">
|
||||
<aside className="hidden md:flex bg-white border border-[rgba(0,0,0,0.08)] rounded-xl p-3 md:p-3 lg:p-[17px] w-[220px] md:w-[220px] lg:w-[240px] h-full max-h-screen flex-col gap-4 md:gap-4 lg:gap-6 shrink-0 overflow-hidden">
|
||||
{/* Logo */}
|
||||
<div className="w-[206px] shrink-0">
|
||||
<div className="w-full md:w-[190px] lg:w-[206px] shrink-0">
|
||||
<div className="flex gap-3 items-center px-2">
|
||||
{!isSuperAdmin && logoUrl ? (
|
||||
<img
|
||||
@ -225,13 +273,17 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||
</div>
|
||||
|
||||
{/* Platform Menu */}
|
||||
<MenuSection title="Platform" items={platformMenu} />
|
||||
{platformMenu.length > 0 && (
|
||||
<MenuSection title="Platform" items={platformMenu} />
|
||||
)}
|
||||
|
||||
{/* System Menu */}
|
||||
<MenuSection title="System" items={systemMenu} />
|
||||
{systemMenu.length > 0 && (
|
||||
<MenuSection title="System" items={systemMenu} />
|
||||
)}
|
||||
|
||||
{/* Support Center */}
|
||||
<div className="mt-auto w-[206px]">
|
||||
<div className="mt-auto w-full">
|
||||
<button className="w-full bg-white border border-[rgba(0,0,0,0.08)] rounded-md px-[13px] py-[9px] flex gap-2.5 items-center hover:bg-gray-50 transition-colors">
|
||||
<HelpCircle className="w-4 h-4 shrink-0 text-[#0f1724]" />
|
||||
<span className="text-[13px] font-medium text-[#0f1724]">Support Center</span>
|
||||
|
||||
@ -7,6 +7,7 @@ import { Loader2, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { Modal, FormField, PrimaryButton, SecondaryButton, MultiselectPaginatedSelect } from '@/components/shared';
|
||||
import type { Role, UpdateRoleRequest } from '@/types/role';
|
||||
import { useAppSelector } from '@/hooks/redux-hooks';
|
||||
import { moduleService } from '@/services/module-service';
|
||||
|
||||
// Utility function to generate code from name
|
||||
const generateCodeFromName = (name: string): string => {
|
||||
@ -68,7 +69,7 @@ const editRoleSchema = z.object({
|
||||
"Code must be lowercase and use '_' for separation (e.g. abc_def)"
|
||||
),
|
||||
description: z.string().min(1, 'Description is required'),
|
||||
module_ids: z.array(z.string().uuid()).optional().nullable(),
|
||||
modules: z.array(z.string().uuid()).optional().nullable(),
|
||||
permissions: z.array(z.object({
|
||||
resource: z.string(),
|
||||
action: z.string(),
|
||||
@ -101,11 +102,11 @@ export const EditRoleModal = ({
|
||||
const loadedRoleIdRef = useRef<string | null>(null);
|
||||
const permissions = useAppSelector((state) => state.auth.permissions);
|
||||
const roles = useAppSelector((state) => state.auth.roles);
|
||||
const tenant = useAppSelector((state) => state.auth.tenant);
|
||||
const tenantIdFromAuth = useAppSelector((state) => state.auth.tenantId);
|
||||
const isSuperAdmin = roles.includes('super_admin');
|
||||
const [selectedModules, setSelectedModules] = useState<string[]>([]);
|
||||
const [selectedAvailableModules, setSelectedAvailableModules] = useState<string[]>([]);
|
||||
const [selectedPermissions, setSelectedPermissions] = useState<Array<{ resource: string; action: string }>>([]);
|
||||
const [initialModuleOptions, setInitialModuleOptions] = useState<Array<{ value: string; label: string }>>([]);
|
||||
const [initialAvailableModuleOptions, setInitialAvailableModuleOptions] = useState<Array<{ value: string; label: string }>>([]);
|
||||
const [expandedResources, setExpandedResources] = useState<Set<string>>(new Set());
|
||||
|
||||
const {
|
||||
@ -120,7 +121,7 @@ export const EditRoleModal = ({
|
||||
} = useForm<EditRoleFormData>({
|
||||
resolver: zodResolver(editRoleSchema),
|
||||
defaultValues: {
|
||||
module_ids: [],
|
||||
modules: [],
|
||||
permissions: [],
|
||||
},
|
||||
});
|
||||
@ -135,25 +136,18 @@ export const EditRoleModal = ({
|
||||
}
|
||||
}, [nameValue, setValue]);
|
||||
|
||||
// Load modules from tenant assignedModules
|
||||
const loadModules = async (page: number, limit: number) => {
|
||||
const assignedModules = tenant?.assignedModules || [];
|
||||
const startIndex = (page - 1) * limit;
|
||||
const endIndex = startIndex + limit;
|
||||
const paginatedModules = assignedModules.slice(startIndex, endIndex);
|
||||
|
||||
// Load available modules from /modules/available endpoint
|
||||
// For super_admin, send tenant_id if defaultTenantId is provided
|
||||
// For tenant users, send tenant_id from auth state
|
||||
const loadAvailableModules = async (page: number, limit: number) => {
|
||||
const tenantId = isSuperAdmin ? defaultTenantId : tenantIdFromAuth;
|
||||
const response = await moduleService.getAvailable(page, limit, tenantId);
|
||||
return {
|
||||
options: paginatedModules.map((module) => ({
|
||||
options: response.data.map((module) => ({
|
||||
value: module.id,
|
||||
label: module.name,
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: assignedModules.length,
|
||||
totalPages: Math.ceil(assignedModules.length / limit),
|
||||
hasMore: endIndex < assignedModules.length,
|
||||
},
|
||||
pagination: response.pagination,
|
||||
};
|
||||
};
|
||||
|
||||
@ -248,6 +242,11 @@ export const EditRoleModal = ({
|
||||
setValue('permissions', selectedPermissions.length > 0 ? selectedPermissions : []);
|
||||
}, [selectedPermissions, setValue]);
|
||||
|
||||
// Update form value when available modules change
|
||||
useEffect(() => {
|
||||
setValue('modules', selectedAvailableModules.length > 0 ? selectedAvailableModules : []);
|
||||
}, [selectedAvailableModules, setValue]);
|
||||
|
||||
// Expand resources that have selected permissions when role is loaded
|
||||
useEffect(() => {
|
||||
if (selectedPermissions.length > 0 && availableResourcesAndActions.size > 0) {
|
||||
@ -292,34 +291,66 @@ export const EditRoleModal = ({
|
||||
const role = await onLoadRole(roleId);
|
||||
loadedRoleIdRef.current = roleId;
|
||||
|
||||
// Extract module_ids and permissions from role
|
||||
const roleModuleIds = role.module_ids || [];
|
||||
// Extract modules and permissions from role
|
||||
const roleModules = role.modules || [];
|
||||
const rolePermissions = role.permissions || [];
|
||||
|
||||
// Set modules if exists and user is not super_admin
|
||||
if (roleModuleIds.length > 0 && !isSuperAdmin) {
|
||||
setSelectedModules(roleModuleIds);
|
||||
setValue('module_ids', roleModuleIds);
|
||||
// Set available modules if exists
|
||||
if (roleModules.length > 0) {
|
||||
setSelectedAvailableModules(roleModules);
|
||||
setValue('modules', roleModules);
|
||||
|
||||
// Load module names from tenant assignedModules
|
||||
const assignedModules = tenant?.assignedModules || [];
|
||||
const moduleOptions = roleModuleIds
|
||||
.map((moduleId: string) => {
|
||||
const module = assignedModules.find((m) => m.id === moduleId);
|
||||
if (module) {
|
||||
return {
|
||||
value: moduleId,
|
||||
label: module.name,
|
||||
};
|
||||
// Load module names from available modules API
|
||||
// Use tenant_id from auth for tenant users, or defaultTenantId for super_admin
|
||||
const loadModuleNames = async () => {
|
||||
try {
|
||||
const tenantId = isSuperAdmin ? defaultTenantId : tenantIdFromAuth;
|
||||
// Load first page of available modules to get module names
|
||||
const availableModulesResponse = await moduleService.getAvailable(1, 100, tenantId);
|
||||
|
||||
// Map role modules to options from available modules
|
||||
const moduleOptions = roleModules
|
||||
.map((moduleId: string) => {
|
||||
const module = availableModulesResponse.data.find((m) => m.id === moduleId);
|
||||
if (module) {
|
||||
return {
|
||||
value: moduleId,
|
||||
label: module.name,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((opt) => opt !== null) as Array<{ value: string; label: string }>;
|
||||
|
||||
setInitialAvailableModuleOptions(moduleOptions);
|
||||
} catch (err) {
|
||||
console.warn('Failed to load available module names:', err);
|
||||
// Fallback: try to load individual modules if available modules endpoint fails
|
||||
try {
|
||||
const moduleOptionsPromises = roleModules.map(async (moduleId: string) => {
|
||||
try {
|
||||
const moduleResponse = await moduleService.getById(moduleId);
|
||||
return {
|
||||
value: moduleId,
|
||||
label: moduleResponse.data.name,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const moduleOptions = (await Promise.all(moduleOptionsPromises)).filter(
|
||||
(opt) => opt !== null
|
||||
) as Array<{ value: string; label: string }>;
|
||||
setInitialAvailableModuleOptions(moduleOptions);
|
||||
} catch (fallbackErr) {
|
||||
console.warn('Fallback loading also failed:', fallbackErr);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((opt) => opt !== null) as Array<{ value: string; label: string }>;
|
||||
setInitialModuleOptions(moduleOptions);
|
||||
}
|
||||
};
|
||||
loadModuleNames();
|
||||
} else {
|
||||
// Clear modules if super_admin or no modules
|
||||
setSelectedModules([]);
|
||||
setInitialModuleOptions([]);
|
||||
setSelectedAvailableModules([]);
|
||||
setInitialAvailableModuleOptions([]);
|
||||
}
|
||||
|
||||
// Set permissions (always set, even if empty array)
|
||||
@ -333,8 +364,7 @@ export const EditRoleModal = ({
|
||||
name: role.name,
|
||||
code: role.code,
|
||||
description: role.description || '',
|
||||
// Only set module_ids if user is not super_admin
|
||||
module_ids: isSuperAdmin ? [] : roleModuleIds,
|
||||
modules: roleModules,
|
||||
permissions: rolePermissions,
|
||||
});
|
||||
} catch (err: any) {
|
||||
@ -348,20 +378,20 @@ export const EditRoleModal = ({
|
||||
} else if (!isOpen) {
|
||||
// Only reset when modal is closed
|
||||
loadedRoleIdRef.current = null;
|
||||
setSelectedModules([]);
|
||||
setSelectedAvailableModules([]);
|
||||
setSelectedPermissions([]);
|
||||
setInitialModuleOptions([]);
|
||||
setInitialAvailableModuleOptions([]);
|
||||
reset({
|
||||
name: '',
|
||||
code: '',
|
||||
description: '',
|
||||
module_ids: [],
|
||||
modules: [],
|
||||
permissions: [],
|
||||
});
|
||||
setLoadError(null);
|
||||
clearErrors();
|
||||
}
|
||||
}, [isOpen, roleId, onLoadRole, reset, clearErrors, setValue, tenant, isSuperAdmin]);
|
||||
}, [isOpen, roleId, onLoadRole, reset, clearErrors, setValue, isSuperAdmin, defaultTenantId, tenantIdFromAuth]);
|
||||
|
||||
const handleFormSubmit = async (data: EditRoleFormData): Promise<void> => {
|
||||
if (!roleId) return;
|
||||
@ -370,10 +400,10 @@ export const EditRoleModal = ({
|
||||
try {
|
||||
const submitData = {
|
||||
...data,
|
||||
// Include tenant_id if defaultTenantId is provided
|
||||
tenant_id: defaultTenantId || undefined,
|
||||
// Only include module_ids if user is not super_admin
|
||||
module_ids: isSuperAdmin ? undefined : (selectedModules.length > 0 ? selectedModules : undefined),
|
||||
// For super_admin, always include tenant_id if defaultTenantId is provided
|
||||
tenant_id: isSuperAdmin ? (defaultTenantId || undefined) : (defaultTenantId || undefined),
|
||||
// Include modules from available modules endpoint
|
||||
modules: selectedAvailableModules.length > 0 ? selectedAvailableModules : undefined,
|
||||
permissions: selectedPermissions.length > 0 ? selectedPermissions : undefined,
|
||||
};
|
||||
await onSubmit(roleId, submitData as UpdateRoleRequest);
|
||||
@ -388,7 +418,7 @@ export const EditRoleModal = ({
|
||||
detail.path === 'name' ||
|
||||
detail.path === 'code' ||
|
||||
detail.path === 'description' ||
|
||||
detail.path === 'module_ids' ||
|
||||
detail.path === 'modules' ||
|
||||
detail.path === 'permissions'
|
||||
) {
|
||||
setError(detail.path as keyof EditRoleFormData, {
|
||||
@ -465,7 +495,7 @@ export const EditRoleModal = ({
|
||||
)}
|
||||
|
||||
{!isLoadingRole && (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-5 flex flex-col gap-0 max-h-[70vh] overflow-y-auto">
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-5 flex flex-col gap-0">
|
||||
{/* Role Name and Role Code Row */}
|
||||
<div className="grid grid-cols-2 gap-5 pb-4">
|
||||
<FormField
|
||||
@ -496,21 +526,19 @@ export const EditRoleModal = ({
|
||||
{...register('description')}
|
||||
/>
|
||||
|
||||
{/* Module Selection - Only show if user is not super_admin */}
|
||||
{!isSuperAdmin && (
|
||||
<MultiselectPaginatedSelect
|
||||
label="Modules"
|
||||
placeholder="Select modules"
|
||||
value={selectedModules}
|
||||
onValueChange={(values) => {
|
||||
setSelectedModules(values);
|
||||
setValue('module_ids', values.length > 0 ? values : []);
|
||||
}}
|
||||
onLoadOptions={loadModules}
|
||||
initialOptions={initialModuleOptions}
|
||||
error={errors.module_ids?.message}
|
||||
/>
|
||||
)}
|
||||
{/* Available Modules Selection */}
|
||||
<MultiselectPaginatedSelect
|
||||
label="Available Modules"
|
||||
placeholder="Select available modules"
|
||||
value={selectedAvailableModules}
|
||||
onValueChange={(values) => {
|
||||
setSelectedAvailableModules(values);
|
||||
setValue('modules', values.length > 0 ? values : []);
|
||||
}}
|
||||
onLoadOptions={loadAvailableModules}
|
||||
initialOptions={initialAvailableModuleOptions}
|
||||
error={errors.modules?.message}
|
||||
/>
|
||||
|
||||
{/* Permissions Section */}
|
||||
<div className="pb-4">
|
||||
@ -520,7 +548,7 @@ export const EditRoleModal = ({
|
||||
{errors.permissions && (
|
||||
<p className="text-sm text-[#ef4444] mb-2">{errors.permissions.message}</p>
|
||||
)}
|
||||
<div className="border border-[rgba(0,0,0,0.08)] rounded-md p-4 max-h-96 overflow-y-auto">
|
||||
<div className="border border-[rgba(0,0,0,0.08)] rounded-md p-4">
|
||||
{Array.from(availableResourcesAndActions.entries()).length === 0 ? (
|
||||
<p className="text-sm text-[#6b7280]">No permissions available</p>
|
||||
) : (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -112,10 +112,10 @@ export const FormSelect = ({
|
||||
<div className="flex flex-col gap-2 pb-4">
|
||||
<label
|
||||
htmlFor={fieldId}
|
||||
className="flex items-center gap-1.5 text-[13px] font-medium text-[#0e1b2a]"
|
||||
className="flex items-center gap-1 text-[13px] font-medium text-[#0e1b2a]"
|
||||
>
|
||||
<span>{label}</span>
|
||||
{required && <span className="text-[#e02424] text-[8px]">*</span>}
|
||||
{required && <span className="text-[#e02424]">*</span>}
|
||||
</label>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
|
||||
@ -7,6 +7,7 @@ import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { Modal, FormField, PrimaryButton, SecondaryButton, MultiselectPaginatedSelect } from '@/components/shared';
|
||||
import type { CreateRoleRequest } from '@/types/role';
|
||||
import { useAppSelector } from '@/hooks/redux-hooks';
|
||||
import { moduleService } from '@/services/module-service';
|
||||
|
||||
// Utility function to generate code from name
|
||||
const generateCodeFromName = (name: string): string => {
|
||||
@ -68,7 +69,7 @@ const newRoleSchema = z.object({
|
||||
"Code must be lowercase and use '_' for separation (e.g. abc_def)"
|
||||
),
|
||||
description: z.string().min(1, 'Description is required'),
|
||||
module_ids: z.array(z.string().uuid()).optional().nullable(),
|
||||
modules: z.array(z.string().uuid()).optional().nullable(),
|
||||
permissions: z.array(z.object({
|
||||
resource: z.string(),
|
||||
action: z.string(),
|
||||
@ -94,9 +95,8 @@ export const NewRoleModal = ({
|
||||
}: NewRoleModalProps): ReactElement | null => {
|
||||
const permissions = useAppSelector((state) => state.auth.permissions);
|
||||
const roles = useAppSelector((state) => state.auth.roles);
|
||||
const tenant = useAppSelector((state) => state.auth.tenant);
|
||||
const isSuperAdmin = roles.includes('super_admin');
|
||||
const [selectedModules, setSelectedModules] = useState<string[]>([]);
|
||||
const [selectedAvailableModules, setSelectedAvailableModules] = useState<string[]>([]);
|
||||
const [selectedPermissions, setSelectedPermissions] = useState<Array<{ resource: string; action: string }>>([]);
|
||||
const [expandedResources, setExpandedResources] = useState<Set<string>>(new Set());
|
||||
|
||||
@ -113,7 +113,7 @@ export const NewRoleModal = ({
|
||||
resolver: zodResolver(newRoleSchema),
|
||||
defaultValues: {
|
||||
code: undefined,
|
||||
module_ids: [],
|
||||
modules: [],
|
||||
permissions: [],
|
||||
},
|
||||
});
|
||||
@ -135,34 +135,26 @@ export const NewRoleModal = ({
|
||||
name: '',
|
||||
code: undefined,
|
||||
description: '',
|
||||
module_ids: [],
|
||||
modules: [],
|
||||
permissions: [],
|
||||
});
|
||||
setSelectedModules([]);
|
||||
setSelectedAvailableModules([]);
|
||||
setSelectedPermissions([]);
|
||||
clearErrors();
|
||||
}
|
||||
}, [isOpen, reset, clearErrors]);
|
||||
|
||||
// Load modules from tenant assignedModules
|
||||
const loadModules = async (page: number, limit: number) => {
|
||||
const assignedModules = tenant?.assignedModules || [];
|
||||
const startIndex = (page - 1) * limit;
|
||||
const endIndex = startIndex + limit;
|
||||
const paginatedModules = assignedModules.slice(startIndex, endIndex);
|
||||
|
||||
// Load available modules from /modules/available endpoint
|
||||
// For super_admin, send tenant_id if defaultTenantId is provided
|
||||
const loadAvailableModules = async (page: number, limit: number) => {
|
||||
const tenantId = isSuperAdmin ? defaultTenantId : undefined;
|
||||
const response = await moduleService.getAvailable(page, limit, tenantId);
|
||||
return {
|
||||
options: paginatedModules.map((module) => ({
|
||||
options: response.data.map((module) => ({
|
||||
value: module.id,
|
||||
label: module.name,
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: assignedModules.length,
|
||||
totalPages: Math.ceil(assignedModules.length / limit),
|
||||
hasMore: endIndex < assignedModules.length,
|
||||
},
|
||||
pagination: response.pagination,
|
||||
};
|
||||
};
|
||||
|
||||
@ -257,15 +249,20 @@ export const NewRoleModal = ({
|
||||
setValue('permissions', selectedPermissions.length > 0 ? selectedPermissions : []);
|
||||
}, [selectedPermissions, setValue]);
|
||||
|
||||
// Update form value when available modules change
|
||||
useEffect(() => {
|
||||
setValue('modules', selectedAvailableModules.length > 0 ? selectedAvailableModules : []);
|
||||
}, [selectedAvailableModules, setValue]);
|
||||
|
||||
const handleFormSubmit = async (data: NewRoleFormData): Promise<void> => {
|
||||
clearErrors();
|
||||
try {
|
||||
const submitData = {
|
||||
...data,
|
||||
// Include tenant_id if defaultTenantId is provided
|
||||
tenant_id: defaultTenantId || undefined,
|
||||
// Only include module_ids if user is not super_admin
|
||||
module_ids: isSuperAdmin ? undefined : (selectedModules.length > 0 ? selectedModules : undefined),
|
||||
// For super_admin, always include tenant_id if defaultTenantId is provided
|
||||
tenant_id: isSuperAdmin ? (defaultTenantId || undefined) : (defaultTenantId || undefined),
|
||||
// Include modules from available modules endpoint
|
||||
modules: selectedAvailableModules.length > 0 ? selectedAvailableModules : undefined,
|
||||
permissions: selectedPermissions.length > 0 ? selectedPermissions : undefined,
|
||||
};
|
||||
await onSubmit(submitData as CreateRoleRequest);
|
||||
@ -278,7 +275,7 @@ export const NewRoleModal = ({
|
||||
detail.path === 'name' ||
|
||||
detail.path === 'code' ||
|
||||
detail.path === 'description' ||
|
||||
detail.path === 'module_ids' ||
|
||||
detail.path === 'modules' ||
|
||||
detail.path === 'permissions'
|
||||
) {
|
||||
setError(detail.path as keyof NewRoleFormData, {
|
||||
@ -333,7 +330,7 @@ export const NewRoleModal = ({
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-5 flex flex-col gap-0 max-h-[70vh] overflow-y-auto">
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-5 flex flex-col gap-0">
|
||||
{/* General Error Display */}
|
||||
{errors.root && (
|
||||
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md mb-4">
|
||||
@ -371,20 +368,18 @@ export const NewRoleModal = ({
|
||||
{...register('description')}
|
||||
/>
|
||||
|
||||
{/* Module Selection - Only show if user is not super_admin */}
|
||||
{!isSuperAdmin && (
|
||||
<MultiselectPaginatedSelect
|
||||
label="Modules"
|
||||
placeholder="Select modules"
|
||||
value={selectedModules}
|
||||
onValueChange={(values) => {
|
||||
setSelectedModules(values);
|
||||
setValue('module_ids', values.length > 0 ? values : []);
|
||||
}}
|
||||
onLoadOptions={loadModules}
|
||||
error={errors.module_ids?.message}
|
||||
/>
|
||||
)}
|
||||
{/* Available Modules Selection */}
|
||||
<MultiselectPaginatedSelect
|
||||
label="Available Modules"
|
||||
placeholder="Select available modules"
|
||||
value={selectedAvailableModules}
|
||||
onValueChange={(values) => {
|
||||
setSelectedAvailableModules(values);
|
||||
setValue('modules', values.length > 0 ? values : []);
|
||||
}}
|
||||
onLoadOptions={loadAvailableModules}
|
||||
error={errors.modules?.message}
|
||||
/>
|
||||
|
||||
{/* Permissions Section */}
|
||||
<div className="pb-4">
|
||||
@ -394,7 +389,7 @@ export const NewRoleModal = ({
|
||||
{errors.permissions && (
|
||||
<p className="text-sm text-[#ef4444] mb-2">{errors.permissions.message}</p>
|
||||
)}
|
||||
<div className="border border-[rgba(0,0,0,0.08)] rounded-md p-4 max-h-96 overflow-y-auto">
|
||||
<div className="border border-[rgba(0,0,0,0.08)] rounded-md p-4">
|
||||
{Array.from(availableResourcesAndActions.entries()).length === 0 ? (
|
||||
<p className="text-sm text-[#6b7280]">No permissions available</p>
|
||||
) : (
|
||||
|
||||
@ -195,10 +195,10 @@ export const PaginatedSelect = ({
|
||||
<div className="flex flex-col gap-2 pb-4">
|
||||
<label
|
||||
htmlFor={fieldId}
|
||||
className="flex items-center gap-1.5 text-[13px] font-medium text-[#0e1b2a]"
|
||||
className="flex items-center gap-1 text-[13px] font-medium text-[#0e1b2a]"
|
||||
>
|
||||
<span>{label}</span>
|
||||
{required && <span className="text-[#e02424] text-[8px]">*</span>}
|
||||
{required && <span className="text-[#e02424]">*</span>}
|
||||
</label>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
|
||||
@ -12,9 +12,6 @@ export { DataTable } from './DataTable';
|
||||
export type { Column } from './DataTable';
|
||||
export { Pagination } from './Pagination';
|
||||
export { FilterDropdown } from './FilterDropdown';
|
||||
// export { NewTenantModal } from './NewTenantModal';
|
||||
export { ViewTenantModal } from './ViewTenantModal';
|
||||
export { EditTenantModal } from './EditTenantModal';
|
||||
export { DeleteConfirmationModal } from './DeleteConfirmationModal';
|
||||
export { NewUserModal } from './NewUserModal';
|
||||
export { ViewUserModal } from './ViewUserModal';
|
||||
@ -22,10 +19,6 @@ export { EditUserModal } from './EditUserModal';
|
||||
export { NewRoleModal } from './NewRoleModal';
|
||||
export { ViewRoleModal } from './ViewRoleModal';
|
||||
export { EditRoleModal } from './EditRoleModal';
|
||||
export { ViewModuleModal } from './ViewModuleModal';
|
||||
export { NewModuleModal } from './NewModuleModal';
|
||||
export { ViewAuditLogModal } from './ViewAuditLogModal';
|
||||
export { PageHeader } from './PageHeader';
|
||||
export type { TabItem } from './PageHeader';
|
||||
export { UsersTable } from './UsersTable';
|
||||
export { RolesTable } from './RolesTable';
|
||||
1155
src/components/superadmin/EditTenantModal.tsx
Normal file
1155
src/components/superadmin/EditTenantModal.tsx
Normal file
File diff suppressed because it is too large
Load Diff
7
src/components/superadmin/index.ts
Normal file
7
src/components/superadmin/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
// NewTenantModal is commented out and not exported - using CreateTenantWizard instead
|
||||
export { ViewTenantModal } from './ViewTenantModal';
|
||||
// export { EditTenantModal } from './EditTenantModal';
|
||||
export { NewModuleModal } from './NewModuleModal';
|
||||
export { ViewModuleModal } from './ViewModuleModal';
|
||||
export { UsersTable } from './UsersTable';
|
||||
export { RolesTable } from './RolesTable';
|
||||
91
src/hooks/usePermissions.ts
Normal file
91
src/hooks/usePermissions.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAppSelector } from './redux-hooks';
|
||||
|
||||
/**
|
||||
* Hook to check user permissions
|
||||
* @returns Object with permission check functions
|
||||
*/
|
||||
export const usePermissions = () => {
|
||||
const { roles, permissions } = useAppSelector((state) => state.auth);
|
||||
|
||||
const isSuperAdmin = useMemo(() => {
|
||||
const rolesArray = Array.isArray(roles) ? roles : [];
|
||||
return rolesArray.includes('super_admin');
|
||||
}, [roles]);
|
||||
|
||||
/**
|
||||
* Check if user has permission for a specific resource and action
|
||||
* @param resource - The resource name (e.g., 'roles', 'users', 'audit_logs')
|
||||
* @param action - The action name (e.g., 'create', 'read', 'update', 'delete', '*')
|
||||
* @returns boolean - true if user has permission, false otherwise
|
||||
*/
|
||||
const hasPermission = useMemo(
|
||||
() => (resource: string, action: string): boolean => {
|
||||
// Super admin has all permissions
|
||||
if (isSuperAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if user has permission with exact match or wildcard
|
||||
return permissions.some((perm) => {
|
||||
// Check resource match (exact or wildcard)
|
||||
const resourceMatches = perm.resource === resource || perm.resource === '*';
|
||||
|
||||
// Check action match (exact or wildcard)
|
||||
const actionMatches = perm.action === action || perm.action === '*';
|
||||
|
||||
return resourceMatches && actionMatches;
|
||||
});
|
||||
},
|
||||
[permissions, isSuperAdmin]
|
||||
);
|
||||
|
||||
/**
|
||||
* Check if user can create a resource
|
||||
*/
|
||||
const canCreate = useMemo(
|
||||
() => (resource: string): boolean => {
|
||||
return hasPermission(resource, '*') || hasPermission(resource, 'create');
|
||||
},
|
||||
[hasPermission]
|
||||
);
|
||||
|
||||
/**
|
||||
* Check if user can read a resource
|
||||
*/
|
||||
const canRead = useMemo(
|
||||
() => (resource: string): boolean => {
|
||||
return hasPermission(resource, '*') || hasPermission(resource, 'read');
|
||||
},
|
||||
[hasPermission]
|
||||
);
|
||||
|
||||
/**
|
||||
* Check if user can update a resource
|
||||
*/
|
||||
const canUpdate = useMemo(
|
||||
() => (resource: string): boolean => {
|
||||
return hasPermission(resource, '*') || hasPermission(resource, 'update');
|
||||
},
|
||||
[hasPermission]
|
||||
);
|
||||
|
||||
/**
|
||||
* Check if user can delete a resource
|
||||
*/
|
||||
const canDelete = useMemo(
|
||||
() => (resource: string): boolean => {
|
||||
return hasPermission(resource, '*') || hasPermission(resource, 'delete');
|
||||
},
|
||||
[hasPermission]
|
||||
);
|
||||
|
||||
return {
|
||||
hasPermission,
|
||||
canCreate,
|
||||
canRead,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
isSuperAdmin,
|
||||
};
|
||||
};
|
||||
@ -1,443 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { Layout } from '@/components/layout/Layout';
|
||||
import {
|
||||
PrimaryButton,
|
||||
StatusBadge,
|
||||
ActionDropdown,
|
||||
NewRoleModal,
|
||||
ViewRoleModal,
|
||||
EditRoleModal,
|
||||
DeleteConfirmationModal,
|
||||
DataTable,
|
||||
Pagination,
|
||||
FilterDropdown,
|
||||
type Column,
|
||||
} from '@/components/shared';
|
||||
import { Plus, Download, ArrowUpDown } from 'lucide-react';
|
||||
import { roleService } from '@/services/role-service';
|
||||
import type { Role, CreateRoleRequest, UpdateRoleRequest } from '@/types/role';
|
||||
import { showToast } from '@/utils/toast';
|
||||
|
||||
// Helper function to format date
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
};
|
||||
|
||||
// Helper function to get scope badge variant
|
||||
const getScopeVariant = (scope: string): 'success' | 'failure' | 'process' => {
|
||||
switch (scope.toLowerCase()) {
|
||||
case 'platform':
|
||||
return 'success';
|
||||
case 'tenant':
|
||||
return 'process';
|
||||
case 'module':
|
||||
return 'failure';
|
||||
default:
|
||||
return 'success';
|
||||
}
|
||||
};
|
||||
|
||||
const Roles = (): ReactElement => {
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||
const [isCreating, setIsCreating] = useState<boolean>(false);
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const [limit, setLimit] = useState<number>(5);
|
||||
const [pagination, setPagination] = useState<{
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasMore: boolean;
|
||||
}>({
|
||||
page: 1,
|
||||
limit: 5,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
// Filter state
|
||||
const [scopeFilter, setScopeFilter] = useState<string | null>(null);
|
||||
const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
||||
|
||||
// View, Edit, Delete modals
|
||||
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
||||
const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
|
||||
const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
|
||||
const [selectedRoleName, setSelectedRoleName] = useState<string>('');
|
||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||
|
||||
const fetchRoles = async (
|
||||
page: number,
|
||||
itemsPerPage: number,
|
||||
scope: string | null = null,
|
||||
sortBy: string[] | null = null
|
||||
): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const response = await roleService.getAll(page, itemsPerPage, scope, sortBy);
|
||||
if (response.success) {
|
||||
setRoles(response.data);
|
||||
setPagination(response.pagination);
|
||||
} else {
|
||||
setError('Failed to load roles');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error?.message || 'Failed to load roles');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoles(currentPage, limit, scopeFilter, orderBy);
|
||||
}, [currentPage, limit, scopeFilter, orderBy]);
|
||||
|
||||
const handleCreateRole = async (data: CreateRoleRequest): Promise<void> => {
|
||||
try {
|
||||
setIsCreating(true);
|
||||
const response = await roleService.create(data);
|
||||
const message = response.message || `Role created successfully`;
|
||||
const description = response.message ? undefined : `${data.name} has been added`;
|
||||
showToast.success(message, description);
|
||||
setIsModalOpen(false);
|
||||
await fetchRoles(currentPage, limit, scopeFilter, orderBy);
|
||||
} catch (err: any) {
|
||||
throw err;
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// View role handler
|
||||
const handleViewRole = (roleId: string): void => {
|
||||
setSelectedRoleId(roleId);
|
||||
setViewModalOpen(true);
|
||||
};
|
||||
|
||||
// Edit role handler
|
||||
const handleEditRole = (roleId: string, roleName: string): void => {
|
||||
setSelectedRoleId(roleId);
|
||||
setSelectedRoleName(roleName);
|
||||
setEditModalOpen(true);
|
||||
};
|
||||
|
||||
// Update role handler
|
||||
const handleUpdateRole = async (
|
||||
id: string,
|
||||
data: UpdateRoleRequest
|
||||
): Promise<void> => {
|
||||
try {
|
||||
setIsUpdating(true);
|
||||
const response = await roleService.update(id, data);
|
||||
const message = response.message || `Role updated successfully`;
|
||||
const description = response.message ? undefined : `${data.name} has been updated`;
|
||||
showToast.success(message, description);
|
||||
setEditModalOpen(false);
|
||||
setSelectedRoleId(null);
|
||||
await fetchRoles(currentPage, limit, scopeFilter, orderBy);
|
||||
} catch (err: any) {
|
||||
throw err;
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete role handler
|
||||
const handleDeleteRole = (roleId: string, roleName: string): void => {
|
||||
setSelectedRoleId(roleId);
|
||||
setSelectedRoleName(roleName);
|
||||
setDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
// Confirm delete handler
|
||||
const handleConfirmDelete = async (): Promise<void> => {
|
||||
if (!selectedRoleId) return;
|
||||
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await roleService.delete(selectedRoleId);
|
||||
setDeleteModalOpen(false);
|
||||
setSelectedRoleId(null);
|
||||
setSelectedRoleName('');
|
||||
await fetchRoles(currentPage, limit, scopeFilter, orderBy);
|
||||
} catch (err: any) {
|
||||
throw err;
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load role for view/edit
|
||||
const loadRole = async (id: string): Promise<Role> => {
|
||||
const response = await roleService.getById(id);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// Table columns
|
||||
const columns: Column<Role>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Name',
|
||||
render: (role) => (
|
||||
<span className="text-sm font-normal text-[#0f1724]">{role.name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'code',
|
||||
label: 'Code',
|
||||
render: (role) => (
|
||||
<span className="text-sm font-normal text-[#0f1724] font-mono">{role.code}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'scope',
|
||||
label: 'Scope',
|
||||
render: (role) => (
|
||||
<StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
label: 'Description',
|
||||
render: (role) => (
|
||||
<span className="text-sm font-normal text-[#6b7280]">
|
||||
{role.description || 'N/A'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'is_system',
|
||||
label: 'System Role',
|
||||
render: (role) => (
|
||||
<span className="text-sm font-normal text-[#0f1724]">
|
||||
{role.is_system ? 'Yes' : 'No'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: 'Created Date',
|
||||
render: (role) => (
|
||||
<span className="text-sm font-normal text-[#6b7280]">{formatDate(role.created_at)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: 'Actions',
|
||||
align: 'right',
|
||||
render: (role) => (
|
||||
<div className="flex justify-end">
|
||||
<ActionDropdown
|
||||
onView={() => handleViewRole(role.id)}
|
||||
onEdit={() => handleEditRole(role.id, role.name)}
|
||||
onDelete={() => handleDeleteRole(role.id, role.name)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Mobile card renderer
|
||||
const mobileCardRenderer = (role: Role) => (
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-[#0f1724] truncate">{role.name}</h3>
|
||||
<p className="text-xs text-[#6b7280] mt-0.5 font-mono">{role.code}</p>
|
||||
</div>
|
||||
<ActionDropdown
|
||||
onView={() => handleViewRole(role.id)}
|
||||
onEdit={() => handleEditRole(role.id, role.name)}
|
||||
onDelete={() => handleDeleteRole(role.id, role.name)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
<div>
|
||||
<span className="text-[#9aa6b2]">Scope:</span>
|
||||
<div className="mt-1">
|
||||
<StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[#9aa6b2]">Created:</span>
|
||||
<p className="text-[#0f1724] font-normal mt-1">{formatDate(role.created_at)}</p>
|
||||
</div>
|
||||
{role.description && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-[#9aa6b2]">Description:</span>
|
||||
<p className="text-[#0f1724] font-normal mt-1">{role.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
currentPage="Roles"
|
||||
pageHeader={{
|
||||
title: 'Role List',
|
||||
description: 'Define and manage roles to control user access based on job responsibilities',
|
||||
}}
|
||||
>
|
||||
{/* Table Container */}
|
||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
|
||||
{/* Table Header with Filters */}
|
||||
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Scope Filter */}
|
||||
<FilterDropdown
|
||||
label="Scope"
|
||||
options={[
|
||||
{ value: 'platform', label: 'Platform' },
|
||||
{ value: 'tenant', label: 'Tenant' },
|
||||
{ value: 'module', label: 'Module' },
|
||||
]}
|
||||
value={scopeFilter}
|
||||
onChange={(value) => {
|
||||
setScopeFilter(value as string | null);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
placeholder="All"
|
||||
/>
|
||||
|
||||
{/* Sort Filter */}
|
||||
<FilterDropdown
|
||||
label="Sort by"
|
||||
options={[
|
||||
{ value: ['name', 'asc'], label: 'Name (A-Z)' },
|
||||
{ value: ['name', 'desc'], label: 'Name (Z-A)' },
|
||||
{ value: ['code', 'asc'], label: 'Code (A-Z)' },
|
||||
{ value: ['code', 'desc'], label: 'Code (Z-A)' },
|
||||
{ value: ['created_at', 'asc'], label: 'Created (Oldest)' },
|
||||
{ value: ['created_at', 'desc'], label: 'Created (Newest)' },
|
||||
{ value: ['updated_at', 'asc'], label: 'Updated (Oldest)' },
|
||||
{ value: ['updated_at', 'desc'], label: 'Updated (Newest)' },
|
||||
]}
|
||||
value={orderBy}
|
||||
onChange={(value) => {
|
||||
setOrderBy(value as string[] | null);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
placeholder="Default"
|
||||
showIcon
|
||||
icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Export Button */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
<span>Export</span>
|
||||
</button>
|
||||
|
||||
{/* New Role Button */}
|
||||
<PrimaryButton
|
||||
size="default"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span className="text-xs">New Role</span>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<DataTable
|
||||
data={roles}
|
||||
columns={columns}
|
||||
keyExtractor={(role) => role.id}
|
||||
mobileCardRenderer={mobileCardRenderer}
|
||||
emptyMessage="No roles found"
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
/>
|
||||
|
||||
{/* Table Footer with Pagination */}
|
||||
{pagination.total > 0 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={pagination.totalPages}
|
||||
totalItems={pagination.total}
|
||||
limit={limit}
|
||||
onPageChange={(page: number) => {
|
||||
setCurrentPage(page);
|
||||
}}
|
||||
onLimitChange={(newLimit: number) => {
|
||||
setLimit(newLimit);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Role Modal */}
|
||||
<NewRoleModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSubmit={handleCreateRole}
|
||||
isLoading={isCreating}
|
||||
/>
|
||||
|
||||
{/* View Role Modal */}
|
||||
<ViewRoleModal
|
||||
isOpen={viewModalOpen}
|
||||
onClose={() => {
|
||||
setViewModalOpen(false);
|
||||
setSelectedRoleId(null);
|
||||
}}
|
||||
roleId={selectedRoleId}
|
||||
onLoadRole={loadRole}
|
||||
/>
|
||||
|
||||
{/* Edit Role Modal */}
|
||||
<EditRoleModal
|
||||
isOpen={editModalOpen}
|
||||
onClose={() => {
|
||||
setEditModalOpen(false);
|
||||
setSelectedRoleId(null);
|
||||
setSelectedRoleName('');
|
||||
}}
|
||||
roleId={selectedRoleId}
|
||||
onLoadRole={loadRole}
|
||||
onSubmit={handleUpdateRole}
|
||||
isLoading={isUpdating}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<DeleteConfirmationModal
|
||||
isOpen={deleteModalOpen}
|
||||
onClose={() => {
|
||||
setDeleteModalOpen(false);
|
||||
setSelectedRoleId(null);
|
||||
setSelectedRoleName('');
|
||||
}}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="Delete Role"
|
||||
message={`Are you sure you want to delete this role`}
|
||||
itemName={selectedRoleName}
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Roles;
|
||||
@ -1,476 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { Layout } from '@/components/layout/Layout';
|
||||
import {
|
||||
PrimaryButton,
|
||||
StatusBadge,
|
||||
ActionDropdown,
|
||||
NewUserModal,
|
||||
ViewUserModal,
|
||||
EditUserModal,
|
||||
DeleteConfirmationModal,
|
||||
DataTable,
|
||||
Pagination,
|
||||
FilterDropdown,
|
||||
type Column,
|
||||
} from '@/components/shared';
|
||||
import { Plus, Download, ArrowUpDown } from 'lucide-react';
|
||||
import { userService } from '@/services/user-service';
|
||||
import type { User } from '@/types/user';
|
||||
import { showToast } from '@/utils/toast';
|
||||
|
||||
// Helper function to get user initials
|
||||
const getUserInitials = (firstName: string, lastName: string): string => {
|
||||
return `${firstName[0]}${lastName[0]}`.toUpperCase();
|
||||
};
|
||||
|
||||
// Helper function to format date
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
};
|
||||
|
||||
// Helper function to get status badge variant
|
||||
const getStatusVariant = (status: string): 'success' | 'failure' | 'process' => {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'active':
|
||||
return 'success';
|
||||
case 'pending_verification':
|
||||
return 'process';
|
||||
case 'inactive':
|
||||
return 'failure';
|
||||
case 'deleted':
|
||||
return 'failure';
|
||||
case 'suspended':
|
||||
return 'process';
|
||||
default:
|
||||
return 'success';
|
||||
}
|
||||
};
|
||||
|
||||
const Users = (): ReactElement => {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||
const [isCreating, setIsCreating] = useState<boolean>(false);
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const [limit, setLimit] = useState<number>(5);
|
||||
const [pagination, setPagination] = useState<{
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasMore: boolean;
|
||||
}>({
|
||||
page: 1,
|
||||
limit: 5,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
// Filter state
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
||||
|
||||
// View, Edit, Delete modals
|
||||
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
||||
const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
const [selectedUserName, setSelectedUserName] = useState<string>('');
|
||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||
|
||||
const fetchUsers = async (
|
||||
page: number,
|
||||
itemsPerPage: number,
|
||||
status: string | null = null,
|
||||
sortBy: string[] | null = null
|
||||
): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const response = await userService.getAll(page, itemsPerPage, status, sortBy);
|
||||
if (response.success) {
|
||||
setUsers(response.data);
|
||||
setPagination(response.pagination);
|
||||
} else {
|
||||
setError('Failed to load users');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error?.message || 'Failed to load users');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers(currentPage, limit, statusFilter, orderBy);
|
||||
}, [currentPage, limit, statusFilter, orderBy]);
|
||||
|
||||
const handleCreateUser = async (data: {
|
||||
email: string;
|
||||
password: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
status: 'active' | 'suspended' | 'deleted';
|
||||
auth_provider: 'local';
|
||||
role_id: string;
|
||||
}): Promise<void> => {
|
||||
try {
|
||||
setIsCreating(true);
|
||||
const response = await userService.create(data);
|
||||
const message = response.message || `User created successfully`;
|
||||
const description = response.message ? undefined : `${data.first_name} ${data.last_name} has been added`;
|
||||
showToast.success(message, description);
|
||||
setIsModalOpen(false);
|
||||
await fetchUsers(currentPage, limit, statusFilter, orderBy);
|
||||
} catch (err: any) {
|
||||
throw err;
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// View user handler
|
||||
const handleViewUser = (userId: string): void => {
|
||||
setSelectedUserId(userId);
|
||||
setViewModalOpen(true);
|
||||
};
|
||||
|
||||
// Edit user handler
|
||||
const handleEditUser = (userId: string, userName: string): void => {
|
||||
setSelectedUserId(userId);
|
||||
setSelectedUserName(userName);
|
||||
setEditModalOpen(true);
|
||||
};
|
||||
|
||||
// Update user handler
|
||||
const handleUpdateUser = async (
|
||||
id: string,
|
||||
data: {
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
status: 'active' | 'suspended' | 'deleted';
|
||||
tenant_id: string;
|
||||
role_id: string;
|
||||
}
|
||||
): Promise<void> => {
|
||||
try {
|
||||
setIsUpdating(true);
|
||||
const response = await userService.update(id, data);
|
||||
const message = response.message || `User updated successfully`;
|
||||
const description = response.message ? undefined : `${data.first_name} ${data.last_name} has been updated`;
|
||||
showToast.success(message, description);
|
||||
setEditModalOpen(false);
|
||||
setSelectedUserId(null);
|
||||
await fetchUsers(currentPage, limit, statusFilter, orderBy);
|
||||
} catch (err: any) {
|
||||
throw err;
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete user handler
|
||||
const handleDeleteUser = (userId: string, userName: string): void => {
|
||||
setSelectedUserId(userId);
|
||||
setSelectedUserName(userName);
|
||||
setDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
// Confirm delete handler
|
||||
const handleConfirmDelete = async (): Promise<void> => {
|
||||
if (!selectedUserId) return;
|
||||
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await userService.delete(selectedUserId);
|
||||
setDeleteModalOpen(false);
|
||||
setSelectedUserId(null);
|
||||
setSelectedUserName('');
|
||||
await fetchUsers(currentPage, limit, statusFilter, orderBy);
|
||||
} catch (err: any) {
|
||||
throw err;
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load user for view/edit
|
||||
const loadUser = async (id: string): Promise<User> => {
|
||||
const response = await userService.getById(id);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// Define table columns
|
||||
const columns: Column<User>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'User Name',
|
||||
render: (user) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0">
|
||||
<span className="text-xs font-normal text-[#9aa6b2]">
|
||||
{getUserInitials(user.first_name, user.last_name)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-normal text-[#0f1724]">
|
||||
{user.first_name} {user.last_name}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
mobileLabel: 'Name',
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: 'Email',
|
||||
render: (user) => <span className="text-sm font-normal text-[#0f1724]">{user.email}</span>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (user) => (
|
||||
<StatusBadge variant={getStatusVariant(user.status)}>{user.status}</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'auth_provider',
|
||||
label: 'Auth Provider',
|
||||
render: (user) => (
|
||||
<span className="text-sm font-normal text-[#0f1724]">{user.auth_provider}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: 'Joined Date',
|
||||
render: (user) => (
|
||||
<span className="text-sm font-normal text-[#6b7280]">{formatDate(user.created_at)}</span>
|
||||
),
|
||||
mobileLabel: 'Joined',
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: 'Actions',
|
||||
align: 'right',
|
||||
render: (user) => (
|
||||
<div className="flex justify-end">
|
||||
<ActionDropdown
|
||||
onView={() => handleViewUser(user.id)}
|
||||
onEdit={() => handleEditUser(user.id, `${user.first_name} ${user.last_name}`)}
|
||||
onDelete={() => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Mobile card renderer
|
||||
const mobileCardRenderer = (user: User) => (
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="w-8 h-8 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0">
|
||||
<span className="text-xs font-normal text-[#9aa6b2]">
|
||||
{getUserInitials(user.first_name, user.last_name)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-[#0f1724] truncate">
|
||||
{user.first_name} {user.last_name}
|
||||
</h3>
|
||||
<p className="text-xs text-[#6b7280] mt-0.5 truncate">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ActionDropdown
|
||||
onView={() => handleViewUser(user.id)}
|
||||
onEdit={() => handleEditUser(user.id, `${user.first_name} ${user.last_name}`)}
|
||||
onDelete={() => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
<div>
|
||||
<span className="text-[#9aa6b2]">Status:</span>
|
||||
<div className="mt-1">
|
||||
<StatusBadge variant={getStatusVariant(user.status)}>{user.status}</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[#9aa6b2]">Auth Provider:</span>
|
||||
<p className="text-[#0f1724] font-normal mt-1">{user.auth_provider}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[#9aa6b2]">Joined:</span>
|
||||
<p className="text-[#6b7280] font-normal mt-1">{formatDate(user.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
currentPage="Users"
|
||||
pageHeader={{
|
||||
title: 'User List',
|
||||
description: 'View and manage all users in your QAssure platform from a single place.',
|
||||
}}
|
||||
>
|
||||
{/* Table Container */}
|
||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
|
||||
{/* Table Header with Filters */}
|
||||
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Status Filter */}
|
||||
<FilterDropdown
|
||||
label="Status"
|
||||
options={[
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'pending_verification', label: 'Pending Verification' },
|
||||
{ value: 'inactive', label: 'Inactive' },
|
||||
{ value: 'suspended', label: 'Suspended' },
|
||||
{ value: 'deleted', label: 'Deleted' },
|
||||
]}
|
||||
value={statusFilter}
|
||||
onChange={(value) => {
|
||||
setStatusFilter(value as string | null);
|
||||
setCurrentPage(1); // Reset to first page when filter changes
|
||||
}}
|
||||
placeholder="All"
|
||||
/>
|
||||
|
||||
{/* Sort Filter */}
|
||||
<FilterDropdown
|
||||
label="Sort by"
|
||||
options={[
|
||||
{ value: ['first_name', 'asc'], label: 'First Name (A-Z)' },
|
||||
{ value: ['first_name', 'desc'], label: 'First Name (Z-A)' },
|
||||
{ value: ['last_name', 'asc'], label: 'Last Name (A-Z)' },
|
||||
{ value: ['last_name', 'desc'], label: 'Last Name (Z-A)' },
|
||||
{ value: ['email', 'asc'], label: 'Email (A-Z)' },
|
||||
{ value: ['email', 'desc'], label: 'Email (Z-A)' },
|
||||
{ value: ['created_at', 'asc'], label: 'Created (Oldest)' },
|
||||
{ value: ['created_at', 'desc'], label: 'Created (Newest)' },
|
||||
{ value: ['updated_at', 'asc'], label: 'Updated (Oldest)' },
|
||||
{ value: ['updated_at', 'desc'], label: 'Updated (Newest)' },
|
||||
]}
|
||||
value={orderBy}
|
||||
onChange={(value) => {
|
||||
setOrderBy(value as string[] | null);
|
||||
setCurrentPage(1); // Reset to first page when sort changes
|
||||
}}
|
||||
placeholder="Default"
|
||||
showIcon
|
||||
icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Export Button */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
<span>Export</span>
|
||||
</button>
|
||||
|
||||
{/* New User Button */}
|
||||
<PrimaryButton
|
||||
size="default"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span className="text-xs">New User</span>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Table */}
|
||||
<DataTable
|
||||
data={users}
|
||||
columns={columns}
|
||||
keyExtractor={(user) => user.id}
|
||||
mobileCardRenderer={mobileCardRenderer}
|
||||
emptyMessage="No users found"
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
/>
|
||||
|
||||
{/* Table Footer with Pagination */}
|
||||
{pagination.total > 0 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={pagination.totalPages}
|
||||
totalItems={pagination.total}
|
||||
limit={limit}
|
||||
onPageChange={(page: number) => {
|
||||
setCurrentPage(page);
|
||||
}}
|
||||
onLimitChange={(newLimit: number) => {
|
||||
setLimit(newLimit);
|
||||
setCurrentPage(1); // Reset to first page when limit changes
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New User Modal */}
|
||||
<NewUserModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSubmit={handleCreateUser}
|
||||
isLoading={isCreating}
|
||||
/>
|
||||
|
||||
{/* View User Modal */}
|
||||
<ViewUserModal
|
||||
isOpen={viewModalOpen}
|
||||
onClose={() => {
|
||||
setViewModalOpen(false);
|
||||
setSelectedUserId(null);
|
||||
}}
|
||||
userId={selectedUserId}
|
||||
onLoadUser={loadUser}
|
||||
/>
|
||||
|
||||
{/* Edit User Modal */}
|
||||
<EditUserModal
|
||||
isOpen={editModalOpen}
|
||||
onClose={() => {
|
||||
setEditModalOpen(false);
|
||||
setSelectedUserId(null);
|
||||
setSelectedUserName('');
|
||||
}}
|
||||
userId={selectedUserId}
|
||||
onLoadUser={loadUser}
|
||||
onSubmit={handleUpdateUser}
|
||||
isLoading={isUpdating}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<DeleteConfirmationModal
|
||||
isOpen={deleteModalOpen}
|
||||
onClose={() => {
|
||||
setDeleteModalOpen(false);
|
||||
setSelectedUserId(null);
|
||||
setSelectedUserName('');
|
||||
}}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="Delete User"
|
||||
message="Are you sure you want to delete this user"
|
||||
itemName={selectedUserName}
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Users;
|
||||
@ -10,7 +10,7 @@ import { tenantService } from '@/services/tenant-service';
|
||||
import { moduleService } from '@/services/module-service';
|
||||
import { fileService } from '@/services/file-service';
|
||||
import { showToast } from '@/utils/toast';
|
||||
import { ChevronRight, ChevronLeft, Image as ImageIcon } from 'lucide-react';
|
||||
import { ChevronRight, ChevronLeft, Image as ImageIcon, X } from 'lucide-react';
|
||||
|
||||
// Step 1: Tenant Details Schema - matches NewTenantModal
|
||||
const tenantDetailsSchema = z.object({
|
||||
@ -52,12 +52,10 @@ const contactDetailsSchema = z
|
||||
.refine(
|
||||
(val) => {
|
||||
if (!val || val.trim() === '') return true; // Optional field, empty is valid
|
||||
// Phone number regex: accepts formats like +1234567890, (123) 456-7890, 123-456-7890, 123.456.7890, etc.
|
||||
const phoneRegex = /^[\+]?[(]?[0-9]{1,4}[)]?[-\s\.]?[(]?[0-9]{1,4}[)]?[-\s\.]?[0-9]{1,9}[-\s\.]?[0-9]{1,9}$/;
|
||||
return phoneRegex.test(val.replace(/\s/g, ''));
|
||||
return /^\d{10}$/.test(val);
|
||||
},
|
||||
{
|
||||
message: 'Please enter a valid phone number (e.g., +1234567890, (123) 456-7890, 123-456-7890)',
|
||||
message: 'Phone number must be exactly 10 digits',
|
||||
}
|
||||
),
|
||||
address_line1: z.string().min(1, 'Address is required'),
|
||||
@ -66,8 +64,7 @@ const contactDetailsSchema = z
|
||||
state: z.string().min(1, 'State is required'),
|
||||
postal_code: z
|
||||
.string()
|
||||
.min(1, 'Postal code is required')
|
||||
.regex(/^[A-Za-z0-9\s\-]{3,10}$/, 'Postal code must be 3-10 characters (letters, numbers, spaces, or hyphens)'),
|
||||
.regex(/^[1-9]\d{5}$/, 'Postal code must be a valid 6-digit PIN code'),
|
||||
country: z.string().min(1, 'Country is required'),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
@ -172,14 +169,16 @@ const CreateTenantWizard = (): ReactElement => {
|
||||
// File upload state for branding
|
||||
const [logoFile, setLogoFile] = useState<File | null>(null);
|
||||
const [faviconFile, setFaviconFile] = useState<File | null>(null);
|
||||
const [logoFilePath, setLogoFilePath] = useState<string | null>(null);
|
||||
// const [logoFilePath, setLogoFilePath] = useState<string | null>(null);
|
||||
const [logoFileUrl, setLogoFileUrl] = useState<string | null>(null);
|
||||
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
|
||||
const [faviconFilePath, setFaviconFilePath] = useState<string | null>(null);
|
||||
// const [faviconFilePath, setFaviconFilePath] = useState<string | null>(null);
|
||||
const [faviconFileUrl, setFaviconFileUrl] = useState<string | null>(null);
|
||||
const [faviconPreviewUrl, setFaviconPreviewUrl] = useState<string | null>(null);
|
||||
const [isUploadingLogo, setIsUploadingLogo] = useState<boolean>(false);
|
||||
const [isUploadingFavicon, setIsUploadingFavicon] = useState<boolean>(false);
|
||||
const [logoError, setLogoError] = useState<string | null>(null);
|
||||
const [faviconError, setFaviconError] = useState<string | null>(null);
|
||||
|
||||
// Auto-generate slug and domain from name
|
||||
const nameValue = tenantDetailsForm.watch('name');
|
||||
@ -204,7 +203,7 @@ const CreateTenantWizard = (): ReactElement => {
|
||||
const host = baseUrlObj.host; // e.g., "localhost:5173"
|
||||
const protocol = baseUrlObj.protocol; // e.g., "http:" or "https:"
|
||||
const autoGeneratedDomain = `${protocol}//${slug}.${host}/tenant`;
|
||||
tenantDetailsForm.setValue('domain', autoGeneratedDomain, { shouldValidate: false });
|
||||
tenantDetailsForm.setValue('domain', autoGeneratedDomain, { shouldValidate: false });
|
||||
} catch {
|
||||
// Fallback if URL parsing fails
|
||||
const autoGeneratedDomain = `${baseUrlWithProtocol.replace(/\/$/, '')}/${slug}/tenant`;
|
||||
@ -294,10 +293,56 @@ const CreateTenantWizard = (): ReactElement => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteLogo = (): void => {
|
||||
if (logoPreviewUrl) {
|
||||
URL.revokeObjectURL(logoPreviewUrl);
|
||||
}
|
||||
setLogoFile(null);
|
||||
setLogoPreviewUrl(null);
|
||||
setLogoFileUrl(null);
|
||||
setLogoError(null);
|
||||
// Reset the file input
|
||||
const fileInput = document.getElementById('logo-upload-wizard') as HTMLInputElement;
|
||||
if (fileInput) {
|
||||
fileInput.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFavicon = (): void => {
|
||||
if (faviconPreviewUrl) {
|
||||
URL.revokeObjectURL(faviconPreviewUrl);
|
||||
}
|
||||
setFaviconFile(null);
|
||||
setFaviconPreviewUrl(null);
|
||||
setFaviconFileUrl(null);
|
||||
setFaviconError(null);
|
||||
// Reset the file input
|
||||
const fileInput = document.getElementById('favicon-upload-wizard') as HTMLInputElement;
|
||||
if (fileInput) {
|
||||
fileInput.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
const isValid = await settingsForm.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
// Validate logo and favicon are uploaded
|
||||
setLogoError(null);
|
||||
setFaviconError(null);
|
||||
|
||||
if (!logoFileUrl && !logoFile) {
|
||||
setLogoError('Logo is required');
|
||||
setCurrentStep(3); // Go to settings step where logo/favicon are
|
||||
return;
|
||||
}
|
||||
|
||||
if (!faviconFileUrl && !faviconFile) {
|
||||
setFaviconError('Favicon is required');
|
||||
setCurrentStep(3); // Go to settings step where logo/favicon are
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const tenantDetails = tenantDetailsForm.getValues();
|
||||
@ -323,8 +368,8 @@ const CreateTenantWizard = (): ReactElement => {
|
||||
primary_color: primary_color || undefined,
|
||||
secondary_color: secondary_color || undefined,
|
||||
accent_color: accent_color || undefined,
|
||||
logo_file_path: logoFilePath || undefined,
|
||||
favicon_file_path: faviconFilePath || undefined,
|
||||
logo_file_path: logoFileUrl || undefined,
|
||||
favicon_file_path: faviconFileUrl || undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -433,26 +478,23 @@ const CreateTenantWizard = (): ReactElement => {
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.number}
|
||||
className={`flex gap-2.5 items-center px-3 py-2 rounded-md ${
|
||||
step.isActive ? 'bg-[#f5f7fa]' : ''
|
||||
}`}
|
||||
className={`flex gap-2.5 items-center px-3 py-2 rounded-md ${step.isActive ? 'bg-[#f5f7fa]' : ''
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${
|
||||
step.isActive
|
||||
? 'bg-[#23dce1] border border-[#23dce1] text-[#112868]'
|
||||
: step.isCompleted
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${step.isActive
|
||||
? 'bg-[#23dce1] border border-[#23dce1] text-[#112868]'
|
||||
: step.isCompleted
|
||||
? 'bg-[#23dce1] border border-[#23dce1] text-[#112868]'
|
||||
: 'bg-white border border-[rgba(0,0,0,0.08)] text-[#6b7280]'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{step.number}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className={`text-sm font-medium ${
|
||||
step.isActive ? 'text-[#0f1724]' : 'text-[#0f1724]'
|
||||
}`}
|
||||
className={`text-sm font-medium ${step.isActive ? 'text-[#0f1724]' : 'text-[#0f1724]'
|
||||
}`}
|
||||
>
|
||||
{step.title}
|
||||
</div>
|
||||
@ -655,9 +697,16 @@ const CreateTenantWizard = (): ReactElement => {
|
||||
<FormField
|
||||
label="Contact Phone"
|
||||
type="tel"
|
||||
placeholder="Enter contact phone"
|
||||
placeholder="Enter 10-digit phone number"
|
||||
maxLength={10}
|
||||
error={contactDetailsForm.formState.errors.contact_phone?.message}
|
||||
{...contactDetailsForm.register('contact_phone')}
|
||||
{...contactDetailsForm.register('contact_phone', {
|
||||
onChange: (e) => {
|
||||
// Only allow digits and limit to 10 characters
|
||||
const value = e.target.value.replace(/\D/g, '').slice(0, 10);
|
||||
contactDetailsForm.setValue('contact_phone', value, { shouldValidate: true });
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -700,7 +749,11 @@ const CreateTenantWizard = (): ReactElement => {
|
||||
required
|
||||
placeholder="Enter postal code"
|
||||
error={contactDetailsForm.formState.errors.postal_code?.message}
|
||||
{...contactDetailsForm.register('postal_code')}
|
||||
{...contactDetailsForm.register('postal_code', {
|
||||
onChange: (e) => {
|
||||
e.target.value = e.target.value.replace(/\D/g, '').slice(0, 6);
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<FormField
|
||||
label="Country"
|
||||
@ -740,7 +793,9 @@ const CreateTenantWizard = (): ReactElement => {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{/* Company Logo */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-[#0f1724]">Company Logo</label>
|
||||
<label className="text-sm font-medium text-[#0f1724]">
|
||||
Company Logo <span className="text-[#e02424]">*</span>
|
||||
</label>
|
||||
<label
|
||||
htmlFor="logo-upload-wizard"
|
||||
className="bg-[#f5f7fa] border border-dashed border-[#d1d5db] rounded-md p-4 flex gap-4 items-center cursor-pointer hover:bg-[#e5e7eb] transition-colors"
|
||||
@ -782,8 +837,9 @@ const CreateTenantWizard = (): ReactElement => {
|
||||
setIsUploadingLogo(true);
|
||||
try {
|
||||
const response = await fileService.uploadSimple(file);
|
||||
setLogoFilePath(response.data.file_path);
|
||||
// setLogoFilePath(response.data.file_path);
|
||||
setLogoFileUrl(response.data.file_url);
|
||||
setLogoError(null); // Clear error on successful upload
|
||||
// Keep preview URL as fallback, will be cleaned up on component unmount or file change
|
||||
showToast.success('Logo uploaded successfully');
|
||||
} catch (err: any) {
|
||||
@ -798,7 +854,7 @@ const CreateTenantWizard = (): ReactElement => {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
setLogoPreviewUrl(null);
|
||||
setLogoFileUrl(null);
|
||||
setLogoFilePath(null);
|
||||
// setLogoFilePath(null);
|
||||
} finally {
|
||||
setIsUploadingLogo(false);
|
||||
}
|
||||
@ -807,13 +863,18 @@ const CreateTenantWizard = (): ReactElement => {
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
{logoFile && (
|
||||
{logoError && (
|
||||
<p className="text-sm text-[#ef4444]">{logoError}</p>
|
||||
)}
|
||||
{(logoFile || logoFileUrl) && (
|
||||
<div className="flex flex-col gap-2 mt-1">
|
||||
<div className="text-xs text-[#6b7280]">
|
||||
{isUploadingLogo ? 'Uploading...' : `Selected: ${logoFile.name}`}
|
||||
</div>
|
||||
{logoFile && (
|
||||
<div className="text-xs text-[#6b7280]">
|
||||
{isUploadingLogo ? 'Uploading...' : `Selected: ${logoFile.name}`}
|
||||
</div>
|
||||
)}
|
||||
{(logoPreviewUrl || logoFileUrl) && (
|
||||
<div className="mt-2">
|
||||
<div className="mt-2 relative inline-block">
|
||||
<img
|
||||
key={logoPreviewUrl || logoFileUrl}
|
||||
src={logoPreviewUrl || logoFileUrl || ''}
|
||||
@ -835,6 +896,14 @@ const CreateTenantWizard = (): ReactElement => {
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteLogo}
|
||||
className="absolute top-1 right-1 bg-[#ef4444] text-white rounded-full p-1 hover:bg-[#dc2626] transition-colors"
|
||||
aria-label="Delete logo"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -843,7 +912,9 @@ const CreateTenantWizard = (): ReactElement => {
|
||||
|
||||
{/* Favicon */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-[#0f1724]">Favicon</label>
|
||||
<label className="text-sm font-medium text-[#0f1724]">
|
||||
Favicon <span className="text-[#e02424]">*</span>
|
||||
</label>
|
||||
<label
|
||||
htmlFor="favicon-upload-wizard"
|
||||
className="bg-[#f5f7fa] border border-dashed border-[#d1d5db] rounded-md p-4 flex gap-4 items-center cursor-pointer hover:bg-[#e5e7eb] transition-colors"
|
||||
@ -885,8 +956,9 @@ const CreateTenantWizard = (): ReactElement => {
|
||||
setIsUploadingFavicon(true);
|
||||
try {
|
||||
const response = await fileService.uploadSimple(file);
|
||||
setFaviconFilePath(response.data.file_path);
|
||||
// setFaviconFilePath(response.data.file_path);
|
||||
setFaviconFileUrl(response.data.file_url);
|
||||
setFaviconError(null); // Clear error on successful upload
|
||||
// Keep preview URL as fallback, will be cleaned up on component unmount or file change
|
||||
showToast.success('Favicon uploaded successfully');
|
||||
} catch (err: any) {
|
||||
@ -901,7 +973,7 @@ const CreateTenantWizard = (): ReactElement => {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
setFaviconPreviewUrl(null);
|
||||
setFaviconFileUrl(null);
|
||||
setFaviconFilePath(null);
|
||||
// setFaviconFilePath(null);
|
||||
} finally {
|
||||
setIsUploadingFavicon(false);
|
||||
}
|
||||
@ -910,13 +982,18 @@ const CreateTenantWizard = (): ReactElement => {
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
{faviconFile && (
|
||||
{faviconError && (
|
||||
<p className="text-sm text-[#ef4444]">{faviconError}</p>
|
||||
)}
|
||||
{(faviconFile || faviconFileUrl) && (
|
||||
<div className="flex flex-col gap-2 mt-1">
|
||||
<div className="text-xs text-[#6b7280]">
|
||||
{isUploadingFavicon ? 'Uploading...' : `Selected: ${faviconFile.name}`}
|
||||
</div>
|
||||
{faviconFile && (
|
||||
<div className="text-xs text-[#6b7280]">
|
||||
{isUploadingFavicon ? 'Uploading...' : `Selected: ${faviconFile.name}`}
|
||||
</div>
|
||||
)}
|
||||
{(faviconPreviewUrl || faviconFileUrl) && (
|
||||
<div className="mt-2">
|
||||
<div className="mt-2 relative inline-block">
|
||||
<img
|
||||
key={faviconFileUrl || faviconPreviewUrl || ''}
|
||||
src={faviconPreviewUrl || faviconFileUrl || ''}
|
||||
@ -938,6 +1015,14 @@ const CreateTenantWizard = (): ReactElement => {
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteFavicon}
|
||||
className="absolute top-1 right-1 bg-[#ef4444] text-white rounded-full p-1 hover:bg-[#dc2626] transition-colors"
|
||||
aria-label="Delete favicon"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -1055,14 +1140,12 @@ const CreateTenantWizard = (): ReactElement => {
|
||||
checked={settingsForm.watch('enable_sso')}
|
||||
/>
|
||||
<div
|
||||
className={`w-10 h-5 rounded-full transition-colors ${
|
||||
settingsForm.watch('enable_sso') ? 'bg-[#23dce1]' : 'bg-[#c0c8e6]'
|
||||
}`}
|
||||
className={`w-10 h-5 rounded-full transition-colors ${settingsForm.watch('enable_sso') ? 'bg-[#23dce1]' : 'bg-[#c0c8e6]'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute top-[2px] left-[2px] bg-white rounded-full h-4 w-4 transition-transform ${
|
||||
settingsForm.watch('enable_sso') ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
className={`absolute top-[2px] left-[2px] bg-white rounded-full h-4 w-4 transition-transform ${settingsForm.watch('enable_sso') ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
</label>
|
||||
@ -1084,14 +1167,12 @@ const CreateTenantWizard = (): ReactElement => {
|
||||
checked={settingsForm.watch('enable_2fa')}
|
||||
/>
|
||||
<div
|
||||
className={`w-10 h-5 rounded-full transition-colors ${
|
||||
settingsForm.watch('enable_2fa') ? 'bg-[#23dce1]' : 'bg-[#c0c8e6]'
|
||||
}`}
|
||||
className={`w-10 h-5 rounded-full transition-colors ${settingsForm.watch('enable_2fa') ? 'bg-[#23dce1]' : 'bg-[#c0c8e6]'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute top-[2px] left-[2px] bg-white rounded-full h-4 w-4 transition-transform ${
|
||||
settingsForm.watch('enable_2fa') ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
className={`absolute top-[2px] left-[2px] bg-white rounded-full h-4 w-4 transition-transform ${settingsForm.watch('enable_2fa') ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
</label>
|
||||
@ -10,7 +10,7 @@ import { tenantService } from '@/services/tenant-service';
|
||||
import { moduleService } from '@/services/module-service';
|
||||
import { fileService } from '@/services/file-service';
|
||||
import { showToast } from '@/utils/toast';
|
||||
import { ChevronRight, ChevronLeft, Image as ImageIcon, Loader2 } from 'lucide-react';
|
||||
import { ChevronRight, ChevronLeft, Image as ImageIcon, Loader2, X } from 'lucide-react';
|
||||
|
||||
// Step 1: Tenant Details Schema
|
||||
const tenantDetailsSchema = z.object({
|
||||
@ -48,12 +48,11 @@ const contactDetailsSchema = z.object({
|
||||
.nullable()
|
||||
.refine(
|
||||
(val) => {
|
||||
if (!val || val.trim() === '') return true;
|
||||
const phoneRegex = /^[\+]?[(]?[0-9]{1,4}[)]?[-\s\.]?[(]?[0-9]{1,4}[)]?[-\s\.]?[0-9]{1,9}[-\s\.]?[0-9]{1,9}$/;
|
||||
return phoneRegex.test(val.replace(/\s/g, ''));
|
||||
if (!val || val.trim() === '') return true; // Optional field, empty is valid
|
||||
return /^\d{10}$/.test(val);
|
||||
},
|
||||
{
|
||||
message: 'Please enter a valid phone number (e.g., +1234567890, (123) 456-7890, 123-456-7890)',
|
||||
message: 'Phone number must be exactly 10 digits',
|
||||
}
|
||||
),
|
||||
address_line1: z.string().min(1, 'Address is required'),
|
||||
@ -62,8 +61,7 @@ const contactDetailsSchema = z.object({
|
||||
state: z.string().min(1, 'State is required'),
|
||||
postal_code: z
|
||||
.string()
|
||||
.min(1, 'Postal code is required')
|
||||
.regex(/^[A-Za-z0-9\s\-]{3,10}$/, 'Postal code must be 3-10 characters (letters, numbers, spaces, or hyphens)'),
|
||||
.regex(/^[1-9]\d{5}$/, 'Postal code must be a valid 6-digit PIN code'),
|
||||
country: z.string().min(1, 'Country is required'),
|
||||
});
|
||||
|
||||
@ -118,6 +116,8 @@ const EditTenant = (): ReactElement => {
|
||||
const [faviconPreviewUrl, setFaviconPreviewUrl] = useState<string | null>(null);
|
||||
const [isUploadingLogo, setIsUploadingLogo] = useState<boolean>(false);
|
||||
const [isUploadingFavicon, setIsUploadingFavicon] = useState<boolean>(false);
|
||||
const [logoError, setLogoError] = useState<string | null>(null);
|
||||
const [faviconError, setFaviconError] = useState<string | null>(null);
|
||||
|
||||
// Form instances for each step
|
||||
const tenantDetailsForm = useForm<TenantDetailsForm>({
|
||||
@ -243,19 +243,21 @@ const EditTenant = (): ReactElement => {
|
||||
setLogoFilePath(logoPath);
|
||||
// const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1';
|
||||
setLogoPreviewUrl(logoPath);
|
||||
setLogoError(null); // Clear error if existing logo is found
|
||||
}
|
||||
if (faviconPath) {
|
||||
setFaviconFilePath(faviconPath);
|
||||
// const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1';
|
||||
setFaviconFileUrl(faviconPath);
|
||||
setFaviconPreviewUrl(faviconPath);
|
||||
setFaviconError(null); // Clear error if existing favicon is found
|
||||
}
|
||||
|
||||
// Validate subscription_tier
|
||||
const validSubscriptionTier =
|
||||
tenant.subscription_tier === 'basic' ||
|
||||
tenant.subscription_tier === 'professional' ||
|
||||
tenant.subscription_tier === 'enterprise'
|
||||
tenant.subscription_tier === 'professional' ||
|
||||
tenant.subscription_tier === 'enterprise'
|
||||
? tenant.subscription_tier
|
||||
: null;
|
||||
|
||||
@ -267,9 +269,9 @@ const EditTenant = (): ReactElement => {
|
||||
// Create initial options from assignedModules
|
||||
const initialOptions = tenant.assignedModules
|
||||
? tenant.assignedModules.map((module) => ({
|
||||
value: module.id,
|
||||
label: module.name,
|
||||
}))
|
||||
value: module.id,
|
||||
label: module.name,
|
||||
}))
|
||||
: [];
|
||||
|
||||
setSelectedModules(tenantModules);
|
||||
@ -390,12 +392,59 @@ const EditTenant = (): ReactElement => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteLogo = (): void => {
|
||||
if (logoPreviewUrl) {
|
||||
URL.revokeObjectURL(logoPreviewUrl);
|
||||
}
|
||||
setLogoFile(null);
|
||||
setLogoPreviewUrl(null);
|
||||
setLogoFilePath(null);
|
||||
setLogoError(null); // Clear error on delete
|
||||
// Reset the file input
|
||||
const fileInput = document.getElementById('logo-upload-edit-page') as HTMLInputElement;
|
||||
if (fileInput) {
|
||||
fileInput.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFavicon = (): void => {
|
||||
if (faviconPreviewUrl) {
|
||||
URL.revokeObjectURL(faviconPreviewUrl);
|
||||
}
|
||||
setFaviconFile(null);
|
||||
setFaviconPreviewUrl(null);
|
||||
setFaviconFileUrl(null);
|
||||
setFaviconFilePath(null);
|
||||
setFaviconError(null); // Clear error on delete
|
||||
// Reset the file input
|
||||
const fileInput = document.getElementById('favicon-upload-edit-page') as HTMLInputElement;
|
||||
if (fileInput) {
|
||||
fileInput.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
if (!id) return;
|
||||
|
||||
const isValid = await settingsForm.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
// Validate logo and favicon are uploaded
|
||||
setLogoError(null);
|
||||
setFaviconError(null);
|
||||
|
||||
if (!logoFilePath) {
|
||||
setLogoError('Logo is required');
|
||||
setCurrentStep(3); // Go to settings step where logo/favicon are
|
||||
return;
|
||||
}
|
||||
|
||||
if (!faviconFilePath) {
|
||||
setFaviconError('Favicon is required');
|
||||
setCurrentStep(3); // Go to settings step where logo/favicon are
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const tenantDetails = tenantDetailsForm.getValues();
|
||||
@ -563,26 +612,23 @@ const EditTenant = (): ReactElement => {
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.number}
|
||||
className={`flex gap-2.5 items-center px-3 py-2 rounded-md ${
|
||||
step.isActive ? 'bg-[#f5f7fa]' : ''
|
||||
}`}
|
||||
className={`flex gap-2.5 items-center px-3 py-2 rounded-md ${step.isActive ? 'bg-[#f5f7fa]' : ''
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${
|
||||
step.isActive
|
||||
? 'bg-[#23dce1] border border-[#23dce1] text-[#112868]'
|
||||
: step.isCompleted
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${step.isActive
|
||||
? 'bg-[#23dce1] border border-[#23dce1] text-[#112868]'
|
||||
: step.isCompleted
|
||||
? 'bg-[#23dce1] border border-[#23dce1] text-[#112868]'
|
||||
: 'bg-white border border-[rgba(0,0,0,0.08)] text-[#6b7280]'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{step.number}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className={`text-sm font-medium ${
|
||||
step.isActive ? 'text-[#0f1724]' : 'text-[#0f1724]'
|
||||
}`}
|
||||
className={`text-sm font-medium ${step.isActive ? 'text-[#0f1724]' : 'text-[#0f1724]'
|
||||
}`}
|
||||
>
|
||||
{step.title}
|
||||
</div>
|
||||
@ -753,9 +799,16 @@ const EditTenant = (): ReactElement => {
|
||||
<FormField
|
||||
label="Contact Phone"
|
||||
type="tel"
|
||||
placeholder="Enter contact phone"
|
||||
placeholder="Enter 10-digit phone number"
|
||||
maxLength={10}
|
||||
error={contactDetailsForm.formState.errors.contact_phone?.message}
|
||||
{...contactDetailsForm.register('contact_phone')}
|
||||
{...contactDetailsForm.register('contact_phone', {
|
||||
onChange: (e) => {
|
||||
// Only allow digits and limit to 10 characters
|
||||
const value = e.target.value.replace(/\D/g, '').slice(0, 10);
|
||||
contactDetailsForm.setValue('contact_phone', value, { shouldValidate: true });
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -797,8 +850,13 @@ const EditTenant = (): ReactElement => {
|
||||
required
|
||||
placeholder="Enter postal code"
|
||||
error={contactDetailsForm.formState.errors.postal_code?.message}
|
||||
{...contactDetailsForm.register('postal_code')}
|
||||
{...contactDetailsForm.register('postal_code', {
|
||||
onChange: (e) => {
|
||||
e.target.value = e.target.value.replace(/\D/g, '').slice(0, 6);
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Country"
|
||||
required
|
||||
@ -834,7 +892,9 @@ const EditTenant = (): ReactElement => {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{/* Company Logo */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-[#0f1724]">Company Logo</label>
|
||||
<label className="text-sm font-medium text-[#0f1724]">
|
||||
Company Logo <span className="text-[#e02424]">*</span>
|
||||
</label>
|
||||
<label
|
||||
htmlFor="logo-upload-edit-page"
|
||||
className="bg-[#f5f7fa] border border-dashed border-[#d1d5db] rounded-md p-4 flex gap-4 items-center cursor-pointer hover:bg-[#e5e7eb] transition-colors"
|
||||
@ -872,6 +932,7 @@ const EditTenant = (): ReactElement => {
|
||||
try {
|
||||
const response = await fileService.uploadSimple(file);
|
||||
setLogoFilePath(response.data.file_url);
|
||||
setLogoError(null); // Clear error on successful upload
|
||||
showToast.success('Logo uploaded successfully');
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
@ -892,13 +953,18 @@ const EditTenant = (): ReactElement => {
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
{logoFile && (
|
||||
{logoError && (
|
||||
<p className="text-sm text-[#ef4444]">{logoError}</p>
|
||||
)}
|
||||
{(logoFile || logoPreviewUrl) && (
|
||||
<div className="flex flex-col gap-2 mt-1">
|
||||
<div className="text-xs text-[#6b7280]">
|
||||
{isUploadingLogo ? 'Uploading...' : `Selected: ${logoFile.name}`}
|
||||
</div>
|
||||
{(logoPreviewUrl ) && (
|
||||
<div className="mt-2">
|
||||
{logoFile && (
|
||||
<div className="text-xs text-[#6b7280]">
|
||||
{isUploadingLogo ? 'Uploading...' : `Selected: ${logoFile.name}`}
|
||||
</div>
|
||||
)}
|
||||
{logoPreviewUrl && (
|
||||
<div className="mt-2 relative inline-block">
|
||||
<img
|
||||
key={logoPreviewUrl}
|
||||
src={logoPreviewUrl || ''}
|
||||
@ -912,25 +978,25 @@ const EditTenant = (): ReactElement => {
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteLogo}
|
||||
className="absolute top-1 right-1 bg-[#ef4444] text-white rounded-full p-1 hover:bg-[#dc2626] transition-colors"
|
||||
aria-label="Delete logo"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!logoFile && (
|
||||
<div className="mt-2">
|
||||
<img
|
||||
src={logoPreviewUrl || ''}
|
||||
alt="Current logo"
|
||||
className="max-w-full h-20 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
|
||||
style={{ display: 'block', maxHeight: '80px' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Favicon */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-[#0f1724]">Favicon</label>
|
||||
<label className="text-sm font-medium text-[#0f1724]">
|
||||
Favicon <span className="text-[#e02424]">*</span>
|
||||
</label>
|
||||
<label
|
||||
htmlFor="favicon-upload-edit-page"
|
||||
className="bg-[#f5f7fa] border border-dashed border-[#d1d5db] rounded-md p-4 flex gap-4 items-center cursor-pointer hover:bg-[#e5e7eb] transition-colors"
|
||||
@ -969,6 +1035,7 @@ const EditTenant = (): ReactElement => {
|
||||
const response = await fileService.uploadSimple(file);
|
||||
setFaviconFilePath(response.data.file_url);
|
||||
setFaviconFileUrl(response.data.file_url);
|
||||
setFaviconError(null); // Clear error on successful upload
|
||||
showToast.success('Favicon uploaded successfully');
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
@ -990,34 +1057,44 @@ const EditTenant = (): ReactElement => {
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
{faviconFile && (
|
||||
{faviconError && (
|
||||
<p className="text-sm text-[#ef4444]">{faviconError}</p>
|
||||
)}
|
||||
{(faviconFile || faviconFileUrl || faviconPreviewUrl) && (
|
||||
<div className="flex flex-col gap-2 mt-1">
|
||||
<div className="text-xs text-[#6b7280]">
|
||||
{isUploadingFavicon ? 'Uploading...' : `Selected: ${faviconFile.name}`}
|
||||
</div>
|
||||
{faviconFile && (
|
||||
<div className="text-xs text-[#6b7280]">
|
||||
{isUploadingFavicon ? 'Uploading...' : `Selected: ${faviconFile.name}`}
|
||||
</div>
|
||||
)}
|
||||
{(faviconPreviewUrl || faviconFileUrl) && (
|
||||
<div className="mt-2">
|
||||
<div className="mt-2 relative inline-block">
|
||||
<img
|
||||
key={faviconPreviewUrl || faviconFileUrl}
|
||||
src={faviconPreviewUrl || faviconFileUrl || ''}
|
||||
alt="Favicon preview"
|
||||
className="max-w-full h-20 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
|
||||
style={{ display: 'block', maxHeight: '80px' }}
|
||||
className="w-16 h-16 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
|
||||
style={{ display: 'block', width: '64px', height: '64px' }}
|
||||
onError={(e) => {
|
||||
console.error('Failed to load favicon preview image', {
|
||||
faviconFileUrl,
|
||||
faviconPreviewUrl,
|
||||
src: e.currentTarget.src,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteFavicon}
|
||||
className="absolute top-1 right-1 bg-[#ef4444] text-white rounded-full p-1 hover:bg-[#dc2626] transition-colors"
|
||||
aria-label="Delete favicon"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!faviconFile && faviconFileUrl && (
|
||||
<div className="mt-2">
|
||||
<img
|
||||
src={faviconFileUrl}
|
||||
alt="Current favicon"
|
||||
className="max-w-full h-20 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
|
||||
style={{ display: 'block', maxHeight: '80px' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1121,14 +1198,12 @@ const EditTenant = (): ReactElement => {
|
||||
checked={settingsForm.watch('enable_sso')}
|
||||
/>
|
||||
<div
|
||||
className={`w-10 h-5 rounded-full transition-colors ${
|
||||
settingsForm.watch('enable_sso') ? 'bg-[#23dce1]' : 'bg-[#c0c8e6]'
|
||||
}`}
|
||||
className={`w-10 h-5 rounded-full transition-colors ${settingsForm.watch('enable_sso') ? 'bg-[#23dce1]' : 'bg-[#c0c8e6]'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute top-[2px] left-[2px] bg-white rounded-full h-4 w-4 transition-transform ${
|
||||
settingsForm.watch('enable_sso') ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
className={`absolute top-[2px] left-[2px] bg-white rounded-full h-4 w-4 transition-transform ${settingsForm.watch('enable_sso') ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
</label>
|
||||
@ -1146,14 +1221,12 @@ const EditTenant = (): ReactElement => {
|
||||
checked={settingsForm.watch('enable_2fa')}
|
||||
/>
|
||||
<div
|
||||
className={`w-10 h-5 rounded-full transition-colors ${
|
||||
settingsForm.watch('enable_2fa') ? 'bg-[#23dce1]' : 'bg-[#c0c8e6]'
|
||||
}`}
|
||||
className={`w-10 h-5 rounded-full transition-colors ${settingsForm.watch('enable_2fa') ? 'bg-[#23dce1]' : 'bg-[#c0c8e6]'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute top-[2px] left-[2px] bg-white rounded-full h-4 w-4 transition-transform ${
|
||||
settingsForm.watch('enable_2fa') ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
className={`absolute top-[2px] left-[2px] bg-white rounded-full h-4 w-4 transition-transform ${settingsForm.watch('enable_2fa') ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
</label>
|
||||
@ -3,14 +3,13 @@ import type { ReactElement } from 'react';
|
||||
import { Layout } from '@/components/layout/Layout';
|
||||
import {
|
||||
StatusBadge,
|
||||
ViewModuleModal,
|
||||
NewModuleModal,
|
||||
PrimaryButton,
|
||||
DataTable,
|
||||
Pagination,
|
||||
FilterDropdown,
|
||||
type Column,
|
||||
} from '@/components/shared';
|
||||
import { ViewModuleModal, NewModuleModal } from '@/components/superadmin';
|
||||
import { Plus, Download, ArrowUpDown } from 'lucide-react';
|
||||
import { moduleService } from '@/services/module-service';
|
||||
import type { Module } from '@/types/module';
|
||||
443
src/pages/superadmin/Roles.tsx
Normal file
443
src/pages/superadmin/Roles.tsx
Normal file
@ -0,0 +1,443 @@
|
||||
// import { useState, useEffect } from 'react';
|
||||
// import type { ReactElement } from 'react';
|
||||
// import { Layout } from '@/components/layout/Layout';
|
||||
// import {
|
||||
// PrimaryButton,
|
||||
// StatusBadge,
|
||||
// ActionDropdown,
|
||||
// NewRoleModal,
|
||||
// ViewRoleModal,
|
||||
// EditRoleModal,
|
||||
// DeleteConfirmationModal,
|
||||
// DataTable,
|
||||
// Pagination,
|
||||
// FilterDropdown,
|
||||
// type Column,
|
||||
// } from '@/components/shared';
|
||||
// import { Plus, Download, ArrowUpDown } from 'lucide-react';
|
||||
// import { roleService } from '@/services/role-service';
|
||||
// import type { Role, CreateRoleRequest, UpdateRoleRequest } from '@/types/role';
|
||||
// import { showToast } from '@/utils/toast';
|
||||
|
||||
// // Helper function to format date
|
||||
// const formatDate = (dateString: string): string => {
|
||||
// const date = new Date(dateString);
|
||||
// return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
// };
|
||||
|
||||
// // Helper function to get scope badge variant
|
||||
// const getScopeVariant = (scope: string): 'success' | 'failure' | 'process' => {
|
||||
// switch (scope.toLowerCase()) {
|
||||
// case 'platform':
|
||||
// return 'success';
|
||||
// case 'tenant':
|
||||
// return 'process';
|
||||
// case 'module':
|
||||
// return 'failure';
|
||||
// default:
|
||||
// return 'success';
|
||||
// }
|
||||
// };
|
||||
|
||||
// const Roles = (): ReactElement => {
|
||||
// const [roles, setRoles] = useState<Role[]>([]);
|
||||
// const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
// const [error, setError] = useState<string | null>(null);
|
||||
// const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||
// const [isCreating, setIsCreating] = useState<boolean>(false);
|
||||
|
||||
// // Pagination state
|
||||
// const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
// const [limit, setLimit] = useState<number>(5);
|
||||
// const [pagination, setPagination] = useState<{
|
||||
// page: number;
|
||||
// limit: number;
|
||||
// total: number;
|
||||
// totalPages: number;
|
||||
// hasMore: boolean;
|
||||
// }>({
|
||||
// page: 1,
|
||||
// limit: 5,
|
||||
// total: 0,
|
||||
// totalPages: 1,
|
||||
// hasMore: false,
|
||||
// });
|
||||
|
||||
// // Filter state
|
||||
// const [scopeFilter, setScopeFilter] = useState<string | null>(null);
|
||||
// const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
||||
|
||||
// // View, Edit, Delete modals
|
||||
// const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
||||
// const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
|
||||
// const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
|
||||
// const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
|
||||
// const [selectedRoleName, setSelectedRoleName] = useState<string>('');
|
||||
// const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||
// const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||
|
||||
// const fetchRoles = async (
|
||||
// page: number,
|
||||
// itemsPerPage: number,
|
||||
// scope: string | null = null,
|
||||
// sortBy: string[] | null = null
|
||||
// ): Promise<void> => {
|
||||
// try {
|
||||
// setIsLoading(true);
|
||||
// setError(null);
|
||||
// const response = await roleService.getAll(page, itemsPerPage, scope, sortBy);
|
||||
// if (response.success) {
|
||||
// setRoles(response.data);
|
||||
// setPagination(response.pagination);
|
||||
// } else {
|
||||
// setError('Failed to load roles');
|
||||
// }
|
||||
// } catch (err: any) {
|
||||
// setError(err?.response?.data?.error?.message || 'Failed to load roles');
|
||||
// } finally {
|
||||
// setIsLoading(false);
|
||||
// }
|
||||
// };
|
||||
|
||||
// useEffect(() => {
|
||||
// fetchRoles(currentPage, limit, scopeFilter, orderBy);
|
||||
// }, [currentPage, limit, scopeFilter, orderBy]);
|
||||
|
||||
// const handleCreateRole = async (data: CreateRoleRequest): Promise<void> => {
|
||||
// try {
|
||||
// setIsCreating(true);
|
||||
// const response = await roleService.create(data);
|
||||
// const message = response.message || `Role created successfully`;
|
||||
// const description = response.message ? undefined : `${data.name} has been added`;
|
||||
// showToast.success(message, description);
|
||||
// setIsModalOpen(false);
|
||||
// await fetchRoles(currentPage, limit, scopeFilter, orderBy);
|
||||
// } catch (err: any) {
|
||||
// throw err;
|
||||
// } finally {
|
||||
// setIsCreating(false);
|
||||
// }
|
||||
// };
|
||||
|
||||
// // View role handler
|
||||
// const handleViewRole = (roleId: string): void => {
|
||||
// setSelectedRoleId(roleId);
|
||||
// setViewModalOpen(true);
|
||||
// };
|
||||
|
||||
// // Edit role handler
|
||||
// const handleEditRole = (roleId: string, roleName: string): void => {
|
||||
// setSelectedRoleId(roleId);
|
||||
// setSelectedRoleName(roleName);
|
||||
// setEditModalOpen(true);
|
||||
// };
|
||||
|
||||
// // Update role handler
|
||||
// const handleUpdateRole = async (
|
||||
// id: string,
|
||||
// data: UpdateRoleRequest
|
||||
// ): Promise<void> => {
|
||||
// try {
|
||||
// setIsUpdating(true);
|
||||
// const response = await roleService.update(id, data);
|
||||
// const message = response.message || `Role updated successfully`;
|
||||
// const description = response.message ? undefined : `${data.name} has been updated`;
|
||||
// showToast.success(message, description);
|
||||
// setEditModalOpen(false);
|
||||
// setSelectedRoleId(null);
|
||||
// await fetchRoles(currentPage, limit, scopeFilter, orderBy);
|
||||
// } catch (err: any) {
|
||||
// throw err;
|
||||
// } finally {
|
||||
// setIsUpdating(false);
|
||||
// }
|
||||
// };
|
||||
|
||||
// // Delete role handler
|
||||
// const handleDeleteRole = (roleId: string, roleName: string): void => {
|
||||
// setSelectedRoleId(roleId);
|
||||
// setSelectedRoleName(roleName);
|
||||
// setDeleteModalOpen(true);
|
||||
// };
|
||||
|
||||
// // Confirm delete handler
|
||||
// const handleConfirmDelete = async (): Promise<void> => {
|
||||
// if (!selectedRoleId) return;
|
||||
|
||||
// try {
|
||||
// setIsDeleting(true);
|
||||
// await roleService.delete(selectedRoleId);
|
||||
// setDeleteModalOpen(false);
|
||||
// setSelectedRoleId(null);
|
||||
// setSelectedRoleName('');
|
||||
// await fetchRoles(currentPage, limit, scopeFilter, orderBy);
|
||||
// } catch (err: any) {
|
||||
// throw err;
|
||||
// } finally {
|
||||
// setIsDeleting(false);
|
||||
// }
|
||||
// };
|
||||
|
||||
// // Load role for view/edit
|
||||
// const loadRole = async (id: string): Promise<Role> => {
|
||||
// const response = await roleService.getById(id);
|
||||
// return response.data;
|
||||
// };
|
||||
|
||||
// // Table columns
|
||||
// const columns: Column<Role>[] = [
|
||||
// {
|
||||
// key: 'name',
|
||||
// label: 'Name',
|
||||
// render: (role) => (
|
||||
// <span className="text-sm font-normal text-[#0f1724]">{role.name}</span>
|
||||
// ),
|
||||
// },
|
||||
// {
|
||||
// key: 'code',
|
||||
// label: 'Code',
|
||||
// render: (role) => (
|
||||
// <span className="text-sm font-normal text-[#0f1724] font-mono">{role.code}</span>
|
||||
// ),
|
||||
// },
|
||||
// {
|
||||
// key: 'scope',
|
||||
// label: 'Scope',
|
||||
// render: (role) => (
|
||||
// <StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge>
|
||||
// ),
|
||||
// },
|
||||
// {
|
||||
// key: 'description',
|
||||
// label: 'Description',
|
||||
// render: (role) => (
|
||||
// <span className="text-sm font-normal text-[#6b7280]">
|
||||
// {role.description || 'N/A'}
|
||||
// </span>
|
||||
// ),
|
||||
// },
|
||||
// {
|
||||
// key: 'is_system',
|
||||
// label: 'System Role',
|
||||
// render: (role) => (
|
||||
// <span className="text-sm font-normal text-[#0f1724]">
|
||||
// {role.is_system ? 'Yes' : 'No'}
|
||||
// </span>
|
||||
// ),
|
||||
// },
|
||||
// {
|
||||
// key: 'created_at',
|
||||
// label: 'Created Date',
|
||||
// render: (role) => (
|
||||
// <span className="text-sm font-normal text-[#6b7280]">{formatDate(role.created_at)}</span>
|
||||
// ),
|
||||
// },
|
||||
// {
|
||||
// key: 'actions',
|
||||
// label: 'Actions',
|
||||
// align: 'right',
|
||||
// render: (role) => (
|
||||
// <div className="flex justify-end">
|
||||
// <ActionDropdown
|
||||
// onView={() => handleViewRole(role.id)}
|
||||
// onEdit={() => handleEditRole(role.id, role.name)}
|
||||
// onDelete={() => handleDeleteRole(role.id, role.name)}
|
||||
// />
|
||||
// </div>
|
||||
// ),
|
||||
// },
|
||||
// ];
|
||||
|
||||
// // Mobile card renderer
|
||||
// const mobileCardRenderer = (role: Role) => (
|
||||
// <div className="p-4">
|
||||
// <div className="flex items-start justify-between gap-3 mb-3">
|
||||
// <div className="flex-1 min-w-0">
|
||||
// <h3 className="text-sm font-medium text-[#0f1724] truncate">{role.name}</h3>
|
||||
// <p className="text-xs text-[#6b7280] mt-0.5 font-mono">{role.code}</p>
|
||||
// </div>
|
||||
// <ActionDropdown
|
||||
// onView={() => handleViewRole(role.id)}
|
||||
// onEdit={() => handleEditRole(role.id, role.name)}
|
||||
// onDelete={() => handleDeleteRole(role.id, role.name)}
|
||||
// />
|
||||
// </div>
|
||||
// <div className="grid grid-cols-2 gap-3 text-xs">
|
||||
// <div>
|
||||
// <span className="text-[#9aa6b2]">Scope:</span>
|
||||
// <div className="mt-1">
|
||||
// <StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge>
|
||||
// </div>
|
||||
// </div>
|
||||
// <div>
|
||||
// <span className="text-[#9aa6b2]">Created:</span>
|
||||
// <p className="text-[#0f1724] font-normal mt-1">{formatDate(role.created_at)}</p>
|
||||
// </div>
|
||||
// {role.description && (
|
||||
// <div className="col-span-2">
|
||||
// <span className="text-[#9aa6b2]">Description:</span>
|
||||
// <p className="text-[#0f1724] font-normal mt-1">{role.description}</p>
|
||||
// </div>
|
||||
// )}
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
|
||||
// return (
|
||||
// <Layout
|
||||
// currentPage="Roles"
|
||||
// pageHeader={{
|
||||
// title: 'Role List',
|
||||
// description: 'Define and manage roles to control user access based on job responsibilities',
|
||||
// }}
|
||||
// >
|
||||
// {/* Table Container */}
|
||||
// <div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
|
||||
// {/* Table Header with Filters */}
|
||||
// <div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||
// {/* Filters */}
|
||||
// <div className="flex flex-wrap items-center gap-3">
|
||||
// {/* Scope Filter */}
|
||||
// <FilterDropdown
|
||||
// label="Scope"
|
||||
// options={[
|
||||
// { value: 'platform', label: 'Platform' },
|
||||
// { value: 'tenant', label: 'Tenant' },
|
||||
// { value: 'module', label: 'Module' },
|
||||
// ]}
|
||||
// value={scopeFilter}
|
||||
// onChange={(value) => {
|
||||
// setScopeFilter(value as string | null);
|
||||
// setCurrentPage(1);
|
||||
// }}
|
||||
// placeholder="All"
|
||||
// />
|
||||
|
||||
// {/* Sort Filter */}
|
||||
// <FilterDropdown
|
||||
// label="Sort by"
|
||||
// options={[
|
||||
// { value: ['name', 'asc'], label: 'Name (A-Z)' },
|
||||
// { value: ['name', 'desc'], label: 'Name (Z-A)' },
|
||||
// { value: ['code', 'asc'], label: 'Code (A-Z)' },
|
||||
// { value: ['code', 'desc'], label: 'Code (Z-A)' },
|
||||
// { value: ['created_at', 'asc'], label: 'Created (Oldest)' },
|
||||
// { value: ['created_at', 'desc'], label: 'Created (Newest)' },
|
||||
// { value: ['updated_at', 'asc'], label: 'Updated (Oldest)' },
|
||||
// { value: ['updated_at', 'desc'], label: 'Updated (Newest)' },
|
||||
// ]}
|
||||
// value={orderBy}
|
||||
// onChange={(value) => {
|
||||
// setOrderBy(value as string[] | null);
|
||||
// setCurrentPage(1);
|
||||
// }}
|
||||
// placeholder="Default"
|
||||
// showIcon
|
||||
// icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />}
|
||||
// />
|
||||
// </div>
|
||||
|
||||
// {/* Actions */}
|
||||
// <div className="flex items-center gap-2">
|
||||
// {/* Export Button */}
|
||||
// <button
|
||||
// type="button"
|
||||
// className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
// >
|
||||
// <Download className="w-3.5 h-3.5" />
|
||||
// <span>Export</span>
|
||||
// </button>
|
||||
|
||||
// {/* New Role Button */}
|
||||
// <PrimaryButton
|
||||
// size="default"
|
||||
// className="flex items-center gap-2"
|
||||
// onClick={() => setIsModalOpen(true)}
|
||||
// >
|
||||
// <Plus className="w-3.5 h-3.5" />
|
||||
// <span className="text-xs">New Role</span>
|
||||
// </PrimaryButton>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// {/* Table */}
|
||||
// <DataTable
|
||||
// data={roles}
|
||||
// columns={columns}
|
||||
// keyExtractor={(role) => role.id}
|
||||
// mobileCardRenderer={mobileCardRenderer}
|
||||
// emptyMessage="No roles found"
|
||||
// isLoading={isLoading}
|
||||
// error={error}
|
||||
// />
|
||||
|
||||
// {/* Table Footer with Pagination */}
|
||||
// {pagination.total > 0 && (
|
||||
// <Pagination
|
||||
// currentPage={currentPage}
|
||||
// totalPages={pagination.totalPages}
|
||||
// totalItems={pagination.total}
|
||||
// limit={limit}
|
||||
// onPageChange={(page: number) => {
|
||||
// setCurrentPage(page);
|
||||
// }}
|
||||
// onLimitChange={(newLimit: number) => {
|
||||
// setLimit(newLimit);
|
||||
// setCurrentPage(1);
|
||||
// }}
|
||||
// />
|
||||
// )}
|
||||
// </div>
|
||||
|
||||
// {/* New Role Modal */}
|
||||
// <NewRoleModal
|
||||
// isOpen={isModalOpen}
|
||||
// onClose={() => setIsModalOpen(false)}
|
||||
// onSubmit={handleCreateRole}
|
||||
// isLoading={isCreating}
|
||||
// />
|
||||
|
||||
// {/* View Role Modal */}
|
||||
// <ViewRoleModal
|
||||
// isOpen={viewModalOpen}
|
||||
// onClose={() => {
|
||||
// setViewModalOpen(false);
|
||||
// setSelectedRoleId(null);
|
||||
// }}
|
||||
// roleId={selectedRoleId}
|
||||
// onLoadRole={loadRole}
|
||||
// />
|
||||
|
||||
// {/* Edit Role Modal */}
|
||||
// <EditRoleModal
|
||||
// isOpen={editModalOpen}
|
||||
// onClose={() => {
|
||||
// setEditModalOpen(false);
|
||||
// setSelectedRoleId(null);
|
||||
// setSelectedRoleName('');
|
||||
// }}
|
||||
// roleId={selectedRoleId}
|
||||
// onLoadRole={loadRole}
|
||||
// onSubmit={handleUpdateRole}
|
||||
// isLoading={isUpdating}
|
||||
// />
|
||||
|
||||
// {/* Delete Confirmation Modal */}
|
||||
// <DeleteConfirmationModal
|
||||
// isOpen={deleteModalOpen}
|
||||
// onClose={() => {
|
||||
// setDeleteModalOpen(false);
|
||||
// setSelectedRoleId(null);
|
||||
// setSelectedRoleName('');
|
||||
// }}
|
||||
// onConfirm={handleConfirmDelete}
|
||||
// title="Delete Role"
|
||||
// message={`Are you sure you want to delete this role`}
|
||||
// itemName={selectedRoleName}
|
||||
// isLoading={isDeleting}
|
||||
// />
|
||||
// </Layout>
|
||||
// );
|
||||
// };
|
||||
|
||||
// export default Roles;
|
||||
@ -20,10 +20,9 @@ import {
|
||||
StatusBadge,
|
||||
DataTable,
|
||||
Pagination,
|
||||
UsersTable,
|
||||
RolesTable,
|
||||
type Column,
|
||||
} from '@/components/shared';
|
||||
import { UsersTable, RolesTable } from '@/components/superadmin';
|
||||
import { tenantService } from '@/services/tenant-service';
|
||||
import { auditLogService } from '@/services/audit-log-service';
|
||||
import type { Tenant, AssignedModule } from '@/types/tenant';
|
||||
@ -5,15 +5,13 @@ import {
|
||||
PrimaryButton,
|
||||
StatusBadge,
|
||||
ActionDropdown,
|
||||
// NewTenantModal, // Commented out - using wizard instead
|
||||
// ViewTenantModal, // Commented out - using details page instead
|
||||
// EditTenantModal, // Commented out - using edit page instead
|
||||
DeleteConfirmationModal,
|
||||
DataTable,
|
||||
Pagination,
|
||||
FilterDropdown,
|
||||
type Column,
|
||||
} from '@/components/shared';
|
||||
// Note: NewTenantModal, ViewTenantModal, EditTenantModal are now in @/components/superadmin (commented out - using wizard/details/edit pages instead)
|
||||
import { Plus, Download, ArrowUpDown } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { tenantService } from '@/services/tenant-service';
|
||||
476
src/pages/superadmin/Users.tsx
Normal file
476
src/pages/superadmin/Users.tsx
Normal file
@ -0,0 +1,476 @@
|
||||
// import { useState, useEffect } from 'react';
|
||||
// import type { ReactElement } from 'react';
|
||||
// import { Layout } from '@/components/layout/Layout';
|
||||
// import {
|
||||
// PrimaryButton,
|
||||
// StatusBadge,
|
||||
// ActionDropdown,
|
||||
// NewUserModal,
|
||||
// ViewUserModal,
|
||||
// EditUserModal,
|
||||
// DeleteConfirmationModal,
|
||||
// DataTable,
|
||||
// Pagination,
|
||||
// FilterDropdown,
|
||||
// type Column,
|
||||
// } from '@/components/shared';
|
||||
// import { Plus, Download, ArrowUpDown } from 'lucide-react';
|
||||
// import { userService } from '@/services/user-service';
|
||||
// import type { User } from '@/types/user';
|
||||
// import { showToast } from '@/utils/toast';
|
||||
|
||||
// // Helper function to get user initials
|
||||
// const getUserInitials = (firstName: string, lastName: string): string => {
|
||||
// return `${firstName[0]}${lastName[0]}`.toUpperCase();
|
||||
// };
|
||||
|
||||
// // Helper function to format date
|
||||
// const formatDate = (dateString: string): string => {
|
||||
// const date = new Date(dateString);
|
||||
// return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
// };
|
||||
|
||||
// // Helper function to get status badge variant
|
||||
// const getStatusVariant = (status: string): 'success' | 'failure' | 'process' => {
|
||||
// switch (status.toLowerCase()) {
|
||||
// case 'active':
|
||||
// return 'success';
|
||||
// case 'pending_verification':
|
||||
// return 'process';
|
||||
// case 'inactive':
|
||||
// return 'failure';
|
||||
// case 'deleted':
|
||||
// return 'failure';
|
||||
// case 'suspended':
|
||||
// return 'process';
|
||||
// default:
|
||||
// return 'success';
|
||||
// }
|
||||
// };
|
||||
|
||||
// const Users = (): ReactElement => {
|
||||
// const [users, setUsers] = useState<User[]>([]);
|
||||
// const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
// const [error, setError] = useState<string | null>(null);
|
||||
// const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||
// const [isCreating, setIsCreating] = useState<boolean>(false);
|
||||
|
||||
// // Pagination state
|
||||
// const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
// const [limit, setLimit] = useState<number>(5);
|
||||
// const [pagination, setPagination] = useState<{
|
||||
// page: number;
|
||||
// limit: number;
|
||||
// total: number;
|
||||
// totalPages: number;
|
||||
// hasMore: boolean;
|
||||
// }>({
|
||||
// page: 1,
|
||||
// limit: 5,
|
||||
// total: 0,
|
||||
// totalPages: 1,
|
||||
// hasMore: false,
|
||||
// });
|
||||
|
||||
// // Filter state
|
||||
// const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
// const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
||||
|
||||
// // View, Edit, Delete modals
|
||||
// const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
||||
// const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
|
||||
// const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
|
||||
// const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
// const [selectedUserName, setSelectedUserName] = useState<string>('');
|
||||
// const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||
// const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||
|
||||
// const fetchUsers = async (
|
||||
// page: number,
|
||||
// itemsPerPage: number,
|
||||
// status: string | null = null,
|
||||
// sortBy: string[] | null = null
|
||||
// ): Promise<void> => {
|
||||
// try {
|
||||
// setIsLoading(true);
|
||||
// setError(null);
|
||||
// const response = await userService.getAll(page, itemsPerPage, status, sortBy);
|
||||
// if (response.success) {
|
||||
// setUsers(response.data);
|
||||
// setPagination(response.pagination);
|
||||
// } else {
|
||||
// setError('Failed to load users');
|
||||
// }
|
||||
// } catch (err: any) {
|
||||
// setError(err?.response?.data?.error?.message || 'Failed to load users');
|
||||
// } finally {
|
||||
// setIsLoading(false);
|
||||
// }
|
||||
// };
|
||||
|
||||
// useEffect(() => {
|
||||
// fetchUsers(currentPage, limit, statusFilter, orderBy);
|
||||
// }, [currentPage, limit, statusFilter, orderBy]);
|
||||
|
||||
// const handleCreateUser = async (data: {
|
||||
// email: string;
|
||||
// password: string;
|
||||
// first_name: string;
|
||||
// last_name: string;
|
||||
// status: 'active' | 'suspended' | 'deleted';
|
||||
// auth_provider: 'local';
|
||||
// role_id: string;
|
||||
// }): Promise<void> => {
|
||||
// try {
|
||||
// setIsCreating(true);
|
||||
// const response = await userService.create(data);
|
||||
// const message = response.message || `User created successfully`;
|
||||
// const description = response.message ? undefined : `${data.first_name} ${data.last_name} has been added`;
|
||||
// showToast.success(message, description);
|
||||
// setIsModalOpen(false);
|
||||
// await fetchUsers(currentPage, limit, statusFilter, orderBy);
|
||||
// } catch (err: any) {
|
||||
// throw err;
|
||||
// } finally {
|
||||
// setIsCreating(false);
|
||||
// }
|
||||
// };
|
||||
|
||||
// // View user handler
|
||||
// const handleViewUser = (userId: string): void => {
|
||||
// setSelectedUserId(userId);
|
||||
// setViewModalOpen(true);
|
||||
// };
|
||||
|
||||
// // Edit user handler
|
||||
// const handleEditUser = (userId: string, userName: string): void => {
|
||||
// setSelectedUserId(userId);
|
||||
// setSelectedUserName(userName);
|
||||
// setEditModalOpen(true);
|
||||
// };
|
||||
|
||||
// // Update user handler
|
||||
// const handleUpdateUser = async (
|
||||
// id: string,
|
||||
// data: {
|
||||
// email: string;
|
||||
// first_name: string;
|
||||
// last_name: string;
|
||||
// status: 'active' | 'suspended' | 'deleted';
|
||||
// tenant_id: string;
|
||||
// role_id: string;
|
||||
// }
|
||||
// ): Promise<void> => {
|
||||
// try {
|
||||
// setIsUpdating(true);
|
||||
// const response = await userService.update(id, data);
|
||||
// const message = response.message || `User updated successfully`;
|
||||
// const description = response.message ? undefined : `${data.first_name} ${data.last_name} has been updated`;
|
||||
// showToast.success(message, description);
|
||||
// setEditModalOpen(false);
|
||||
// setSelectedUserId(null);
|
||||
// await fetchUsers(currentPage, limit, statusFilter, orderBy);
|
||||
// } catch (err: any) {
|
||||
// throw err;
|
||||
// } finally {
|
||||
// setIsUpdating(false);
|
||||
// }
|
||||
// };
|
||||
|
||||
// // Delete user handler
|
||||
// const handleDeleteUser = (userId: string, userName: string): void => {
|
||||
// setSelectedUserId(userId);
|
||||
// setSelectedUserName(userName);
|
||||
// setDeleteModalOpen(true);
|
||||
// };
|
||||
|
||||
// // Confirm delete handler
|
||||
// const handleConfirmDelete = async (): Promise<void> => {
|
||||
// if (!selectedUserId) return;
|
||||
|
||||
// try {
|
||||
// setIsDeleting(true);
|
||||
// await userService.delete(selectedUserId);
|
||||
// setDeleteModalOpen(false);
|
||||
// setSelectedUserId(null);
|
||||
// setSelectedUserName('');
|
||||
// await fetchUsers(currentPage, limit, statusFilter, orderBy);
|
||||
// } catch (err: any) {
|
||||
// throw err;
|
||||
// } finally {
|
||||
// setIsDeleting(false);
|
||||
// }
|
||||
// };
|
||||
|
||||
// // Load user for view/edit
|
||||
// const loadUser = async (id: string): Promise<User> => {
|
||||
// const response = await userService.getById(id);
|
||||
// return response.data;
|
||||
// };
|
||||
|
||||
// // Define table columns
|
||||
// const columns: Column<User>[] = [
|
||||
// {
|
||||
// key: 'name',
|
||||
// label: 'User Name',
|
||||
// render: (user) => (
|
||||
// <div className="flex items-center gap-3">
|
||||
// <div className="w-8 h-8 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0">
|
||||
// <span className="text-xs font-normal text-[#9aa6b2]">
|
||||
// {getUserInitials(user.first_name, user.last_name)}
|
||||
// </span>
|
||||
// </div>
|
||||
// <span className="text-sm font-normal text-[#0f1724]">
|
||||
// {user.first_name} {user.last_name}
|
||||
// </span>
|
||||
// </div>
|
||||
// ),
|
||||
// mobileLabel: 'Name',
|
||||
// },
|
||||
// {
|
||||
// key: 'email',
|
||||
// label: 'Email',
|
||||
// render: (user) => <span className="text-sm font-normal text-[#0f1724]">{user.email}</span>,
|
||||
// },
|
||||
// {
|
||||
// key: 'status',
|
||||
// label: 'Status',
|
||||
// render: (user) => (
|
||||
// <StatusBadge variant={getStatusVariant(user.status)}>{user.status}</StatusBadge>
|
||||
// ),
|
||||
// },
|
||||
// {
|
||||
// key: 'auth_provider',
|
||||
// label: 'Auth Provider',
|
||||
// render: (user) => (
|
||||
// <span className="text-sm font-normal text-[#0f1724]">{user.auth_provider}</span>
|
||||
// ),
|
||||
// },
|
||||
// {
|
||||
// key: 'created_at',
|
||||
// label: 'Joined Date',
|
||||
// render: (user) => (
|
||||
// <span className="text-sm font-normal text-[#6b7280]">{formatDate(user.created_at)}</span>
|
||||
// ),
|
||||
// mobileLabel: 'Joined',
|
||||
// },
|
||||
// {
|
||||
// key: 'actions',
|
||||
// label: 'Actions',
|
||||
// align: 'right',
|
||||
// render: (user) => (
|
||||
// <div className="flex justify-end">
|
||||
// <ActionDropdown
|
||||
// onView={() => handleViewUser(user.id)}
|
||||
// onEdit={() => handleEditUser(user.id, `${user.first_name} ${user.last_name}`)}
|
||||
// onDelete={() => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`)}
|
||||
// />
|
||||
// </div>
|
||||
// ),
|
||||
// },
|
||||
// ];
|
||||
|
||||
// // Mobile card renderer
|
||||
// const mobileCardRenderer = (user: User) => (
|
||||
// <div className="p-4">
|
||||
// <div className="flex items-start justify-between gap-3 mb-3">
|
||||
// <div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
// <div className="w-8 h-8 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0">
|
||||
// <span className="text-xs font-normal text-[#9aa6b2]">
|
||||
// {getUserInitials(user.first_name, user.last_name)}
|
||||
// </span>
|
||||
// </div>
|
||||
// <div className="flex-1 min-w-0">
|
||||
// <h3 className="text-sm font-medium text-[#0f1724] truncate">
|
||||
// {user.first_name} {user.last_name}
|
||||
// </h3>
|
||||
// <p className="text-xs text-[#6b7280] mt-0.5 truncate">{user.email}</p>
|
||||
// </div>
|
||||
// </div>
|
||||
// <ActionDropdown
|
||||
// onView={() => handleViewUser(user.id)}
|
||||
// onEdit={() => handleEditUser(user.id, `${user.first_name} ${user.last_name}`)}
|
||||
// onDelete={() => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`)}
|
||||
// />
|
||||
// </div>
|
||||
// <div className="grid grid-cols-2 gap-3 text-xs">
|
||||
// <div>
|
||||
// <span className="text-[#9aa6b2]">Status:</span>
|
||||
// <div className="mt-1">
|
||||
// <StatusBadge variant={getStatusVariant(user.status)}>{user.status}</StatusBadge>
|
||||
// </div>
|
||||
// </div>
|
||||
// <div>
|
||||
// <span className="text-[#9aa6b2]">Auth Provider:</span>
|
||||
// <p className="text-[#0f1724] font-normal mt-1">{user.auth_provider}</p>
|
||||
// </div>
|
||||
// <div>
|
||||
// <span className="text-[#9aa6b2]">Joined:</span>
|
||||
// <p className="text-[#6b7280] font-normal mt-1">{formatDate(user.created_at)}</p>
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
|
||||
// return (
|
||||
// <Layout
|
||||
// currentPage="Users"
|
||||
// pageHeader={{
|
||||
// title: 'User List',
|
||||
// description: 'View and manage all users in your QAssure platform from a single place.',
|
||||
// }}
|
||||
// >
|
||||
// {/* Table Container */}
|
||||
// <div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
|
||||
// {/* Table Header with Filters */}
|
||||
// <div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||
// {/* Filters */}
|
||||
// <div className="flex flex-wrap items-center gap-3">
|
||||
// {/* Status Filter */}
|
||||
// <FilterDropdown
|
||||
// label="Status"
|
||||
// options={[
|
||||
// { value: 'active', label: 'Active' },
|
||||
// { value: 'pending_verification', label: 'Pending Verification' },
|
||||
// { value: 'inactive', label: 'Inactive' },
|
||||
// { value: 'suspended', label: 'Suspended' },
|
||||
// { value: 'deleted', label: 'Deleted' },
|
||||
// ]}
|
||||
// value={statusFilter}
|
||||
// onChange={(value) => {
|
||||
// setStatusFilter(value as string | null);
|
||||
// setCurrentPage(1); // Reset to first page when filter changes
|
||||
// }}
|
||||
// placeholder="All"
|
||||
// />
|
||||
|
||||
// {/* Sort Filter */}
|
||||
// <FilterDropdown
|
||||
// label="Sort by"
|
||||
// options={[
|
||||
// { value: ['first_name', 'asc'], label: 'First Name (A-Z)' },
|
||||
// { value: ['first_name', 'desc'], label: 'First Name (Z-A)' },
|
||||
// { value: ['last_name', 'asc'], label: 'Last Name (A-Z)' },
|
||||
// { value: ['last_name', 'desc'], label: 'Last Name (Z-A)' },
|
||||
// { value: ['email', 'asc'], label: 'Email (A-Z)' },
|
||||
// { value: ['email', 'desc'], label: 'Email (Z-A)' },
|
||||
// { value: ['created_at', 'asc'], label: 'Created (Oldest)' },
|
||||
// { value: ['created_at', 'desc'], label: 'Created (Newest)' },
|
||||
// { value: ['updated_at', 'asc'], label: 'Updated (Oldest)' },
|
||||
// { value: ['updated_at', 'desc'], label: 'Updated (Newest)' },
|
||||
// ]}
|
||||
// value={orderBy}
|
||||
// onChange={(value) => {
|
||||
// setOrderBy(value as string[] | null);
|
||||
// setCurrentPage(1); // Reset to first page when sort changes
|
||||
// }}
|
||||
// placeholder="Default"
|
||||
// showIcon
|
||||
// icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />}
|
||||
// />
|
||||
// </div>
|
||||
|
||||
// {/* Actions */}
|
||||
// <div className="flex items-center gap-2">
|
||||
// {/* Export Button */}
|
||||
// <button
|
||||
// type="button"
|
||||
// className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
// >
|
||||
// <Download className="w-3.5 h-3.5" />
|
||||
// <span>Export</span>
|
||||
// </button>
|
||||
|
||||
// {/* New User Button */}
|
||||
// <PrimaryButton
|
||||
// size="default"
|
||||
// className="flex items-center gap-2"
|
||||
// onClick={() => setIsModalOpen(true)}
|
||||
// >
|
||||
// <Plus className="w-3.5 h-3.5" />
|
||||
// <span className="text-xs">New User</span>
|
||||
// </PrimaryButton>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// {/* Data Table */}
|
||||
// <DataTable
|
||||
// data={users}
|
||||
// columns={columns}
|
||||
// keyExtractor={(user) => user.id}
|
||||
// mobileCardRenderer={mobileCardRenderer}
|
||||
// emptyMessage="No users found"
|
||||
// isLoading={isLoading}
|
||||
// error={error}
|
||||
// />
|
||||
|
||||
// {/* Table Footer with Pagination */}
|
||||
// {pagination.total > 0 && (
|
||||
// <Pagination
|
||||
// currentPage={currentPage}
|
||||
// totalPages={pagination.totalPages}
|
||||
// totalItems={pagination.total}
|
||||
// limit={limit}
|
||||
// onPageChange={(page: number) => {
|
||||
// setCurrentPage(page);
|
||||
// }}
|
||||
// onLimitChange={(newLimit: number) => {
|
||||
// setLimit(newLimit);
|
||||
// setCurrentPage(1); // Reset to first page when limit changes
|
||||
// }}
|
||||
// />
|
||||
// )}
|
||||
// </div>
|
||||
|
||||
// {/* New User Modal */}
|
||||
// <NewUserModal
|
||||
// isOpen={isModalOpen}
|
||||
// onClose={() => setIsModalOpen(false)}
|
||||
// onSubmit={handleCreateUser}
|
||||
// isLoading={isCreating}
|
||||
// />
|
||||
|
||||
// {/* View User Modal */}
|
||||
// <ViewUserModal
|
||||
// isOpen={viewModalOpen}
|
||||
// onClose={() => {
|
||||
// setViewModalOpen(false);
|
||||
// setSelectedUserId(null);
|
||||
// }}
|
||||
// userId={selectedUserId}
|
||||
// onLoadUser={loadUser}
|
||||
// />
|
||||
|
||||
// {/* Edit User Modal */}
|
||||
// <EditUserModal
|
||||
// isOpen={editModalOpen}
|
||||
// onClose={() => {
|
||||
// setEditModalOpen(false);
|
||||
// setSelectedUserId(null);
|
||||
// setSelectedUserName('');
|
||||
// }}
|
||||
// userId={selectedUserId}
|
||||
// onLoadUser={loadUser}
|
||||
// onSubmit={handleUpdateUser}
|
||||
// isLoading={isUpdating}
|
||||
// />
|
||||
|
||||
// {/* Delete Confirmation Modal */}
|
||||
// <DeleteConfirmationModal
|
||||
// isOpen={deleteModalOpen}
|
||||
// onClose={() => {
|
||||
// setDeleteModalOpen(false);
|
||||
// setSelectedUserId(null);
|
||||
// setSelectedUserName('');
|
||||
// }}
|
||||
// onConfirm={handleConfirmDelete}
|
||||
// title="Delete User"
|
||||
// message="Are you sure you want to delete this user"
|
||||
// itemName={selectedUserName}
|
||||
// isLoading={isDeleting}
|
||||
// />
|
||||
// </Layout>
|
||||
// );
|
||||
// };
|
||||
|
||||
// export default Users;
|
||||
@ -1,7 +1,140 @@
|
||||
import { Layout } from '@/components/layout/Layout'
|
||||
import { type ReactElement } from 'react'
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { Layout } from '@/components/layout/Layout';
|
||||
import {
|
||||
StatusBadge,
|
||||
DataTable,
|
||||
type Column,
|
||||
} from '@/components/shared';
|
||||
import { useAppSelector } from '@/hooks/redux-hooks';
|
||||
import { tenantService } from '@/services/tenant-service';
|
||||
import type { AssignedModule } from '@/types/tenant';
|
||||
|
||||
// Helper function to get status badge variant
|
||||
const getStatusVariant = (status: string | null): 'success' | 'failure' | 'process' => {
|
||||
if (!status) return 'process';
|
||||
switch (status.toLowerCase()) {
|
||||
case 'running':
|
||||
case 'active':
|
||||
return 'success';
|
||||
case 'stopped':
|
||||
case 'failed':
|
||||
return 'failure';
|
||||
default:
|
||||
return 'process';
|
||||
}
|
||||
};
|
||||
|
||||
const Modules = (): ReactElement => {
|
||||
const tenantId = useAppSelector((state) => state.auth.tenantId);
|
||||
const [modules, setModules] = useState<AssignedModule[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchTenantModules = async (): Promise<void> => {
|
||||
if (!tenantId) {
|
||||
setError('Tenant ID not found');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const response = await tenantService.getById(tenantId);
|
||||
if (response.success && response.data.assignedModules) {
|
||||
setModules(response.data.assignedModules);
|
||||
} else {
|
||||
setError('Failed to load modules');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error?.message || 'Failed to load modules');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTenantModules();
|
||||
}, [tenantId]);
|
||||
|
||||
// Define table columns
|
||||
const columns: Column<AssignedModule>[] = [
|
||||
{
|
||||
key: 'module_id',
|
||||
label: 'Module ID',
|
||||
render: (module) => (
|
||||
<span className="text-sm font-normal text-[#0f1724] font-mono">{module.module_id}</span>
|
||||
),
|
||||
mobileLabel: 'ID',
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Name',
|
||||
render: (module) => (
|
||||
<span className="text-sm font-normal text-[#0f1724]">{module.name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'version',
|
||||
label: 'Version',
|
||||
render: (module) => (
|
||||
<span className="text-sm font-normal text-[#0f1724]">{module.version}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (module) => (
|
||||
<StatusBadge variant={getStatusVariant(module.status)}>
|
||||
{module.status || 'Unknown'}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'base_url',
|
||||
label: 'Base URL',
|
||||
render: (module) => (
|
||||
<span className="text-sm font-normal text-[#6b7280] font-mono truncate max-w-[200px]">
|
||||
{module.base_url || 'N/A'}
|
||||
</span>
|
||||
),
|
||||
mobileLabel: 'URL',
|
||||
},
|
||||
];
|
||||
|
||||
// Mobile card renderer
|
||||
const mobileCardRenderer = (module: AssignedModule) => (
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-[#0f1724] truncate">{module.name}</h3>
|
||||
<p className="text-xs text-[#6b7280] mt-0.5 truncate font-mono">{module.module_id}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
<div>
|
||||
<span className="text-[#9aa6b2]">Version:</span>
|
||||
<p className="text-[#0f1724] font-normal mt-1">{module.version}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[#9aa6b2]">Status:</span>
|
||||
<div className="mt-1">
|
||||
<StatusBadge variant={getStatusVariant(module.status)}>
|
||||
{module.status || 'Unknown'}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="text-[#9aa6b2]">Base URL:</span>
|
||||
<p className="text-[#0f1724] font-normal mt-1 font-mono truncate">
|
||||
{module.base_url || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
currentPage="Modules"
|
||||
@ -10,10 +143,21 @@ const Modules = (): ReactElement => {
|
||||
description: 'View and manage all system modules registered in the QAssure platform.',
|
||||
}}
|
||||
>
|
||||
|
||||
<div>Modules</div>
|
||||
{/* Table Container */}
|
||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden w-full max-w-full">
|
||||
{/* Data Table */}
|
||||
<DataTable
|
||||
data={modules}
|
||||
columns={columns}
|
||||
keyExtractor={(module) => module.id}
|
||||
mobileCardRenderer={mobileCardRenderer}
|
||||
emptyMessage="No modules assigned to this tenant"
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Modules
|
||||
export default Modules;
|
||||
@ -18,6 +18,7 @@ import { roleService } from '@/services/role-service';
|
||||
import type { Role, CreateRoleRequest, UpdateRoleRequest } from '@/types/role';
|
||||
import { showToast } from '@/utils/toast';
|
||||
import { NewRoleModal } from '@/components/shared/NewRoleModal';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
|
||||
// Helper function to format date
|
||||
const formatDate = (dateString: string): string => {
|
||||
@ -40,6 +41,7 @@ const getScopeVariant = (scope: string): 'success' | 'failure' | 'process' => {
|
||||
};
|
||||
|
||||
const Roles = (): ReactElement => {
|
||||
const { canCreate, canUpdate, canDelete } = usePermissions();
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -240,8 +242,8 @@ const Roles = (): ReactElement => {
|
||||
<div className="flex justify-end">
|
||||
<ActionDropdown
|
||||
onView={() => handleViewRole(role.id)}
|
||||
onEdit={() => handleEditRole(role.id, role.name)}
|
||||
onDelete={() => handleDeleteRole(role.id, role.name)}
|
||||
onEdit={canUpdate('roles') ? () => handleEditRole(role.id, role.name) : undefined}
|
||||
onDelete={canDelete('roles') ? () => handleDeleteRole(role.id, role.name) : undefined}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
@ -258,8 +260,8 @@ const Roles = (): ReactElement => {
|
||||
</div>
|
||||
<ActionDropdown
|
||||
onView={() => handleViewRole(role.id)}
|
||||
onEdit={() => handleEditRole(role.id, role.name)}
|
||||
onDelete={() => handleDeleteRole(role.id, role.name)}
|
||||
onEdit={canUpdate('roles') ? () => handleEditRole(role.id, role.name) : undefined}
|
||||
onDelete={canDelete('roles') ? () => handleDeleteRole(role.id, role.name) : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
@ -349,14 +351,16 @@ const Roles = (): ReactElement => {
|
||||
</button>
|
||||
|
||||
{/* New Role Button */}
|
||||
<PrimaryButton
|
||||
size="default"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span className="text-xs">New Role</span>
|
||||
</PrimaryButton>
|
||||
{canCreate('roles') && (
|
||||
<PrimaryButton
|
||||
size="default"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span className="text-xs">New Role</span>
|
||||
</PrimaryButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Layout } from '@/components/layout/Layout';
|
||||
import { ImageIcon, Loader2 } from 'lucide-react';
|
||||
import { ImageIcon, Loader2, X } from 'lucide-react';
|
||||
import { useState, useEffect, type ReactElement } from 'react';
|
||||
import { useAppSelector, useAppDispatch } from '@/hooks/redux-hooks';
|
||||
import { tenantService } from '@/services/tenant-service';
|
||||
@ -38,6 +38,8 @@ const Settings = (): ReactElement => {
|
||||
const [faviconFileUrl, setFaviconFileUrl] = useState<string | null>(null);
|
||||
const [faviconPreviewUrl, setFaviconPreviewUrl] = useState<string | null>(null);
|
||||
const [isUploadingFavicon, setIsUploadingFavicon] = useState<boolean>(false);
|
||||
const [logoError, setLogoError] = useState<string | null>(null);
|
||||
const [faviconError, setFaviconError] = useState<string | null>(null);
|
||||
|
||||
// Fetch tenant data on mount
|
||||
useEffect(() => {
|
||||
@ -66,12 +68,14 @@ const Settings = (): ReactElement => {
|
||||
if (tenantData.logo_file_path) {
|
||||
setLogoFileUrl(tenantData.logo_file_path);
|
||||
setLogoFilePath(tenantData.logo_file_path);
|
||||
setLogoError(null); // Clear error if existing logo is found
|
||||
}
|
||||
|
||||
// Set favicon
|
||||
if (tenantData.favicon_file_path) {
|
||||
setFaviconFileUrl(tenantData.favicon_file_path);
|
||||
setFaviconFilePath(tenantData.favicon_file_path);
|
||||
setFaviconError(null); // Clear error if existing favicon is found
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
@ -133,6 +137,7 @@ const Settings = (): ReactElement => {
|
||||
const response = await fileService.uploadSimple(file);
|
||||
setLogoFilePath(response.data.file_url);
|
||||
setLogoFileUrl(response.data.file_url);
|
||||
setLogoError(null); // Clear error on successful upload
|
||||
showToast.success('Logo uploaded successfully');
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
@ -182,6 +187,7 @@ const Settings = (): ReactElement => {
|
||||
const response = await fileService.uploadSimple(file);
|
||||
setFaviconFilePath(response.data.file_url);
|
||||
setFaviconFileUrl(response.data.file_url);
|
||||
setFaviconError(null); // Clear error on successful upload
|
||||
showToast.success('Favicon uploaded successfully');
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
@ -200,9 +206,55 @@ const Settings = (): ReactElement => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteLogo = (): void => {
|
||||
if (logoPreviewUrl) {
|
||||
URL.revokeObjectURL(logoPreviewUrl);
|
||||
}
|
||||
setLogoFile(null);
|
||||
setLogoPreviewUrl(null);
|
||||
setLogoFileUrl(null);
|
||||
setLogoFilePath(null);
|
||||
setLogoError(null); // Clear error on delete
|
||||
// Reset the file input
|
||||
const fileInput = document.getElementById('logo-upload') as HTMLInputElement;
|
||||
if (fileInput) {
|
||||
fileInput.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFavicon = (): void => {
|
||||
if (faviconPreviewUrl) {
|
||||
URL.revokeObjectURL(faviconPreviewUrl);
|
||||
}
|
||||
setFaviconFile(null);
|
||||
setFaviconPreviewUrl(null);
|
||||
setFaviconFileUrl(null);
|
||||
setFaviconFilePath(null);
|
||||
setFaviconError(null); // Clear error on delete
|
||||
// Reset the file input
|
||||
const fileInput = document.getElementById('favicon-upload') as HTMLInputElement;
|
||||
if (fileInput) {
|
||||
fileInput.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
if (!tenantId || !tenant) return;
|
||||
|
||||
// Validate logo and favicon are uploaded
|
||||
setLogoError(null);
|
||||
setFaviconError(null);
|
||||
|
||||
if (!logoFilePath) {
|
||||
setLogoError('Logo is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!faviconFilePath) {
|
||||
setFaviconError('Favicon is required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
@ -333,7 +385,9 @@ const Settings = (): ReactElement => {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{/* Company Logo */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-[#0f1724]">Company Logo</label>
|
||||
<label className="text-sm font-medium text-[#0f1724]">
|
||||
Company Logo <span className="text-[#e02424]">*</span>
|
||||
</label>
|
||||
<label
|
||||
htmlFor="logo-upload"
|
||||
className="bg-[#f5f7fa] border border-dashed border-[#d1d5db] rounded-md p-4 flex gap-4 items-center cursor-pointer hover:bg-[#e5e7eb] transition-colors"
|
||||
@ -358,13 +412,18 @@ const Settings = (): ReactElement => {
|
||||
disabled={isUploadingLogo}
|
||||
/>
|
||||
</label>
|
||||
{logoFile && (
|
||||
{logoError && (
|
||||
<p className="text-sm text-[#ef4444]">{logoError}</p>
|
||||
)}
|
||||
{(logoFile || logoFileUrl) && (
|
||||
<div className="flex flex-col gap-2 mt-1">
|
||||
<div className="text-xs text-[#6b7280]">
|
||||
{isUploadingLogo ? 'Uploading...' : `Selected: ${logoFile.name}`}
|
||||
</div>
|
||||
{logoFile && (
|
||||
<div className="text-xs text-[#6b7280]">
|
||||
{isUploadingLogo ? 'Uploading...' : `Selected: ${logoFile.name}`}
|
||||
</div>
|
||||
)}
|
||||
{(logoPreviewUrl || logoFileUrl) && (
|
||||
<div className="mt-2">
|
||||
<div className="mt-2 relative inline-block">
|
||||
<img
|
||||
src={logoPreviewUrl || logoFileUrl || ''}
|
||||
alt="Logo preview"
|
||||
@ -378,25 +437,25 @@ const Settings = (): ReactElement => {
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteLogo}
|
||||
className="absolute top-1 right-1 bg-[#ef4444] text-white rounded-full p-1 hover:bg-[#dc2626] transition-colors"
|
||||
aria-label="Delete logo"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!logoFile && logoFileUrl && (
|
||||
<div className="mt-2">
|
||||
<img
|
||||
src={logoFileUrl}
|
||||
alt="Current logo"
|
||||
className="max-w-full h-20 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
|
||||
style={{ display: 'block', maxHeight: '80px' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Favicon */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-[#0f1724]">Favicon</label>
|
||||
<label className="text-sm font-medium text-[#0f1724]">
|
||||
Favicon <span className="text-[#e02424]">*</span>
|
||||
</label>
|
||||
<label
|
||||
htmlFor="favicon-upload"
|
||||
className="bg-[#f5f7fa] border border-dashed border-[#d1d5db] rounded-md p-4 flex gap-4 items-center cursor-pointer hover:bg-[#e5e7eb] transition-colors"
|
||||
@ -421,13 +480,18 @@ const Settings = (): ReactElement => {
|
||||
disabled={isUploadingFavicon}
|
||||
/>
|
||||
</label>
|
||||
{faviconFile && (
|
||||
{faviconError && (
|
||||
<p className="text-sm text-[#ef4444]">{faviconError}</p>
|
||||
)}
|
||||
{(faviconFile || faviconFileUrl) && (
|
||||
<div className="flex flex-col gap-2 mt-1">
|
||||
<div className="text-xs text-[#6b7280]">
|
||||
{isUploadingFavicon ? 'Uploading...' : `Selected: ${faviconFile.name}`}
|
||||
</div>
|
||||
{faviconFile && (
|
||||
<div className="text-xs text-[#6b7280]">
|
||||
{isUploadingFavicon ? 'Uploading...' : `Selected: ${faviconFile.name}`}
|
||||
</div>
|
||||
)}
|
||||
{(faviconPreviewUrl || faviconFileUrl) && (
|
||||
<div className="mt-2">
|
||||
<div className="mt-2 relative inline-block">
|
||||
<img
|
||||
src={faviconPreviewUrl || faviconFileUrl || ''}
|
||||
alt="Favicon preview"
|
||||
@ -441,20 +505,18 @@ const Settings = (): ReactElement => {
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteFavicon}
|
||||
className="absolute top-1 right-1 bg-[#ef4444] text-white rounded-full p-1 hover:bg-[#dc2626] transition-colors"
|
||||
aria-label="Delete favicon"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!faviconFile && faviconFileUrl && (
|
||||
<div className="mt-2">
|
||||
<img
|
||||
src={faviconFileUrl}
|
||||
alt="Current favicon"
|
||||
className="w-16 h-16 object-contain border border-[#d1d5db] rounded-md p-2 bg-white"
|
||||
style={{ display: 'block', width: '64px', height: '64px' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ import { Plus, Download, ArrowUpDown } from 'lucide-react';
|
||||
import { userService } from '@/services/user-service';
|
||||
import type { User } from '@/types/user';
|
||||
import { showToast } from '@/utils/toast';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
|
||||
// Helper function to get user initials
|
||||
const getUserInitials = (firstName: string, lastName: string): string => {
|
||||
@ -49,6 +50,7 @@ const getStatusVariant = (status: string): 'success' | 'failure' | 'process' =>
|
||||
};
|
||||
|
||||
const Users = (): ReactElement => {
|
||||
const { canCreate, canUpdate, canDelete } = usePermissions();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -262,8 +264,8 @@ const Users = (): ReactElement => {
|
||||
<div className="flex justify-end">
|
||||
<ActionDropdown
|
||||
onView={() => handleViewUser(user.id)}
|
||||
onEdit={() => handleEditUser(user.id, `${user.first_name} ${user.last_name}`)}
|
||||
onDelete={() => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`)}
|
||||
onEdit={canUpdate('users') ? () => handleEditUser(user.id, `${user.first_name} ${user.last_name}`) : undefined}
|
||||
onDelete={canDelete('users') ? () => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`) : undefined}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
@ -289,8 +291,8 @@ const Users = (): ReactElement => {
|
||||
</div>
|
||||
<ActionDropdown
|
||||
onView={() => handleViewUser(user.id)}
|
||||
onEdit={() => handleEditUser(user.id, `${user.first_name} ${user.last_name}`)}
|
||||
onDelete={() => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`)}
|
||||
onEdit={canUpdate('users') ? () => handleEditUser(user.id, `${user.first_name} ${user.last_name}`) : undefined}
|
||||
onDelete={canDelete('users') ? () => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`) : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
@ -382,14 +384,16 @@ const Users = (): ReactElement => {
|
||||
</button>
|
||||
|
||||
{/* New User Button */}
|
||||
<PrimaryButton
|
||||
size="default"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span className="text-xs">New User</span>
|
||||
</PrimaryButton>
|
||||
{canCreate('users') && (
|
||||
<PrimaryButton
|
||||
size="default"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span className="text-xs">New User</span>
|
||||
</PrimaryButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import Dashboard from '@/pages/Dashboard';
|
||||
import Tenants from '@/pages/Tenants';
|
||||
import CreateTenantWizard from '@/pages/CreateTenantWizard';
|
||||
import EditTenant from '@/pages/EditTenant';
|
||||
import TenantDetails from '@/pages/TenantDetails';
|
||||
import Users from '@/pages/Users';
|
||||
import Roles from '@/pages/Roles';
|
||||
import Modules from '@/pages/Modules';
|
||||
import AuditLogs from '@/pages/AuditLogs';
|
||||
import Dashboard from '@/pages/superadmin/Dashboard';
|
||||
import Tenants from '@/pages/superadmin/Tenants';
|
||||
import CreateTenantWizard from '@/pages/superadmin/CreateTenantWizard';
|
||||
import EditTenant from '@/pages/superadmin/EditTenant';
|
||||
import TenantDetails from '@/pages/superadmin/TenantDetails';
|
||||
// import Users from '@/pages/superadmin/Users';
|
||||
// import Roles from '@/pages/superadmin/Roles';
|
||||
import Modules from '@/pages/superadmin/Modules';
|
||||
import AuditLogs from '@/pages/superadmin/AuditLogs';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
export interface RouteConfig {
|
||||
@ -36,14 +36,14 @@ export const superAdminRoutes: RouteConfig[] = [
|
||||
path: '/tenants/:id',
|
||||
element: <TenantDetails />,
|
||||
},
|
||||
{
|
||||
path: '/users',
|
||||
element: <Users />,
|
||||
},
|
||||
{
|
||||
path: '/roles',
|
||||
element: <Roles />,
|
||||
},
|
||||
// {
|
||||
// path: '/users',
|
||||
// element: <Users />,
|
||||
// },
|
||||
// {
|
||||
// path: '/roles',
|
||||
// element: <Roles />,
|
||||
// },
|
||||
{
|
||||
path: '/modules',
|
||||
element: <Modules />,
|
||||
|
||||
@ -44,6 +44,20 @@ export const moduleService = {
|
||||
const response = await apiClient.get<ModulesResponse>(`/modules?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
getAvailable: async (
|
||||
page: number = 1,
|
||||
limit: number = 100,
|
||||
tenantId?: string | null
|
||||
): Promise<ModulesResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', String(page));
|
||||
params.append('limit', String(limit));
|
||||
if (tenantId) {
|
||||
params.append('tenant_id', tenantId);
|
||||
}
|
||||
const response = await apiClient.get<ModulesResponse>(`/modules/available?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
getById: async (id: string): Promise<GetModuleResponse> => {
|
||||
const response = await apiClient.get<GetModuleResponse>(`/modules/${id}`);
|
||||
return response.data;
|
||||
|
||||
@ -7,6 +7,7 @@ export interface Role {
|
||||
is_system?: boolean;
|
||||
tenant_id?: string | null;
|
||||
module_ids?: string[] | null;
|
||||
modules?: string[] | null;
|
||||
permissions?: Permission[] | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@ -37,6 +38,7 @@ export interface CreateRoleRequest {
|
||||
description: string;
|
||||
tenant_id?: string | null;
|
||||
module_ids?: string[] | null;
|
||||
modules?: string[] | null;
|
||||
permissions?: Permission[] | null;
|
||||
}
|
||||
|
||||
@ -57,6 +59,7 @@ export interface UpdateRoleRequest {
|
||||
description: string;
|
||||
tenant_id?: string | null;
|
||||
module_ids?: string[] | null;
|
||||
modules?: string[] | null;
|
||||
permissions?: Permission[] | null;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user