Enhance tenant management by adding CreateTenantWizard and TenantDetails components for streamlined tenant creation and viewing. Update routing in App component to include new paths for tenant management. Refactor Header and Layout components to support breadcrumb navigation. Improve EditRoleModal, EditUserModal, and NewRoleModal to include defaultTenantId for automatic tenant association in role and user management. Update API services to support tenant-specific data fetching for roles and users.
This commit is contained in:
parent
0264d5caf5
commit
f07db4040e
18
src/App.tsx
18
src/App.tsx
@ -3,6 +3,8 @@ import { Toaster } from "sonner";
|
||||
import Login from "./pages/Login";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import Tenants from "./pages/Tenants";
|
||||
import CreateTenantWizard from "./pages/CreateTenantWizard";
|
||||
import TenantDetails from "./pages/TenantDetails";
|
||||
import Users from "./pages/Users";
|
||||
import NotFound from "./pages/NotFound";
|
||||
import ProtectedRoute from "./pages/ProtectedRoute";
|
||||
@ -36,6 +38,22 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/tenants/create-wizard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<CreateTenantWizard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/tenants/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<TenantDetails />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
|
||||
@ -14,7 +14,7 @@ interface HeaderProps {
|
||||
onMenuClick?: () => void;
|
||||
}
|
||||
|
||||
export const Header = ({ currentPage, onMenuClick }: HeaderProps): ReactElement => {
|
||||
export const Header = ({ breadcrumbs, currentPage, onMenuClick }: HeaderProps): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const { user, isLoading } = useAppSelector((state) => state.auth);
|
||||
@ -105,11 +105,42 @@ export const Header = ({ currentPage, onMenuClick }: HeaderProps): ReactElement
|
||||
|
||||
{/* Breadcrumbs */}
|
||||
<nav className="flex items-center gap-1.5 md:gap-2">
|
||||
<span className="text-xs md:text-[13px] font-normal text-[#6b7280]">QAssure</span>
|
||||
{breadcrumbs && breadcrumbs.length > 0 ? (
|
||||
<>
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<div key={index} className="flex items-center gap-1.5 md:gap-2">
|
||||
{crumb.path ? (
|
||||
<button
|
||||
onClick={() => navigate(crumb.path!)}
|
||||
className="text-xs md:text-[13px] font-normal text-[#6b7280] hover:text-[#0f1724] transition-colors cursor-pointer"
|
||||
>
|
||||
{crumb.label}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs md:text-[13px] font-medium text-[#0f1724] truncate max-w-[120px] md:max-w-none">
|
||||
{crumb.label}
|
||||
</span>
|
||||
)}
|
||||
{index < breadcrumbs.length - 1 && (
|
||||
<ChevronRight className="w-3 h-3 md:w-3.5 md:h-3.5 text-[#6b7280]" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
className="text-xs md:text-[13px] font-normal text-[#6b7280] hover:text-[#0f1724] transition-colors cursor-pointer"
|
||||
>
|
||||
QAssure
|
||||
</button>
|
||||
<ChevronRight className="w-3 h-3 md:w-3.5 md:h-3.5 text-[#6b7280]" />
|
||||
<span className="text-xs md:text-[13px] font-medium text-[#0f1724] truncate max-w-[120px] md:max-w-none">
|
||||
{currentPage}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import type { ReactNode, ReactElement } from 'react';
|
||||
interface LayoutProps {
|
||||
children: ReactNode;
|
||||
currentPage: string;
|
||||
breadcrumbs?: Array<{ label: string; path?: string }>;
|
||||
pageHeader?: {
|
||||
title: string;
|
||||
description?: string;
|
||||
@ -14,7 +15,7 @@ interface LayoutProps {
|
||||
};
|
||||
}
|
||||
|
||||
export const Layout = ({ children, currentPage, pageHeader }: LayoutProps): ReactElement => {
|
||||
export const Layout = ({ children, currentPage, breadcrumbs, pageHeader }: LayoutProps): ReactElement => {
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(false);
|
||||
|
||||
const toggleSidebar = (): void => {
|
||||
@ -47,7 +48,7 @@ export const Layout = ({ children, currentPage, pageHeader }: LayoutProps): Reac
|
||||
{/* 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">
|
||||
{/* Top Header */}
|
||||
<Header currentPage={currentPage} onMenuClick={toggleSidebar} />
|
||||
<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">
|
||||
|
||||
@ -84,6 +84,7 @@ interface EditRoleModalProps {
|
||||
onLoadRole: (id: string) => Promise<Role>;
|
||||
onSubmit: (id: string, data: UpdateRoleRequest) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
defaultTenantId?: string; // If provided, automatically include tenant_id in request body
|
||||
}
|
||||
|
||||
export const EditRoleModal = ({
|
||||
@ -93,6 +94,7 @@ export const EditRoleModal = ({
|
||||
onLoadRole,
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
defaultTenantId,
|
||||
}: EditRoleModalProps): ReactElement | null => {
|
||||
const [isLoadingRole, setIsLoadingRole] = useState<boolean>(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
@ -368,6 +370,8 @@ 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),
|
||||
permissions: selectedPermissions.length > 0 ? selectedPermissions : undefined,
|
||||
|
||||
@ -37,6 +37,7 @@ interface EditUserModalProps {
|
||||
onLoadUser: (id: string) => Promise<User>;
|
||||
onSubmit: (id: string, data: EditUserFormData) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
defaultTenantId?: string; // If provided, automatically set tenant_id and hide tenant field
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
@ -52,6 +53,7 @@ export const EditUserModal = ({
|
||||
onLoadUser,
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
defaultTenantId,
|
||||
}: EditUserModalProps): ReactElement | null => {
|
||||
const [isLoadingUser, setIsLoadingUser] = useState<boolean>(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
@ -141,7 +143,10 @@ export const EditUserModal = ({
|
||||
|
||||
// Load roles for dropdown - ensure selected role is included
|
||||
const loadRoles = async (page: number, limit: number) => {
|
||||
const response = await roleService.getAll(page, limit);
|
||||
// If defaultTenantId is provided, filter roles by tenant_id
|
||||
const response = defaultTenantId
|
||||
? await roleService.getByTenant(defaultTenantId, page, limit)
|
||||
: await roleService.getAll(page, limit);
|
||||
let options = response.data.map((role) => ({
|
||||
value: role.id,
|
||||
label: role.name,
|
||||
@ -232,9 +237,14 @@ export const EditUserModal = ({
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
status: user.status,
|
||||
tenant_id: tenantId,
|
||||
tenant_id: defaultTenantId || tenantId,
|
||||
role_id: roleId,
|
||||
});
|
||||
|
||||
// If defaultTenantId is provided, override tenant_id
|
||||
if (defaultTenantId) {
|
||||
setValue('tenant_id', defaultTenantId, { shouldValidate: true });
|
||||
}
|
||||
} catch (err: any) {
|
||||
setLoadError(err?.response?.data?.error?.message || 'Failed to load user details');
|
||||
} finally {
|
||||
@ -257,19 +267,23 @@ export const EditUserModal = ({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
status: 'active',
|
||||
tenant_id: '',
|
||||
tenant_id: defaultTenantId || '',
|
||||
role_id: '',
|
||||
});
|
||||
setLoadError(null);
|
||||
clearErrors();
|
||||
}
|
||||
}, [isOpen, userId, onLoadUser, reset, clearErrors]);
|
||||
}, [isOpen, userId, onLoadUser, reset, clearErrors, defaultTenantId, setValue]);
|
||||
|
||||
const handleFormSubmit = async (data: EditUserFormData): Promise<void> => {
|
||||
if (!userId) return;
|
||||
|
||||
clearErrors();
|
||||
try {
|
||||
// Ensure tenant_id is set from defaultTenantId if provided
|
||||
if (defaultTenantId) {
|
||||
data.tenant_id = defaultTenantId;
|
||||
}
|
||||
await onSubmit(userId, data);
|
||||
} catch (error: any) {
|
||||
// Handle validation errors from API
|
||||
@ -390,7 +404,8 @@ export const EditUserModal = ({
|
||||
</div>
|
||||
|
||||
{/* Tenant and Role Row */}
|
||||
<div className="grid grid-cols-2 gap-5 pb-4">
|
||||
<div className={`grid ${defaultTenantId ? 'grid-cols-1' : 'grid-cols-2'} gap-5 pb-4`}>
|
||||
{!defaultTenantId && (
|
||||
<PaginatedSelect
|
||||
label="Assign Tenant"
|
||||
required
|
||||
@ -401,6 +416,7 @@ export const EditUserModal = ({
|
||||
initialOption={initialTenantOption || undefined}
|
||||
error={errors.tenant_id?.message}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PaginatedSelect
|
||||
label="Assign Role"
|
||||
|
||||
@ -82,6 +82,7 @@ interface NewRoleModalProps {
|
||||
onClose: () => void;
|
||||
onSubmit: (data: CreateRoleRequest) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
defaultTenantId?: string; // If provided, automatically include tenant_id in request body
|
||||
}
|
||||
|
||||
export const NewRoleModal = ({
|
||||
@ -89,6 +90,7 @@ export const NewRoleModal = ({
|
||||
onClose,
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
defaultTenantId,
|
||||
}: NewRoleModalProps): ReactElement | null => {
|
||||
const permissions = useAppSelector((state) => state.auth.permissions);
|
||||
const roles = useAppSelector((state) => state.auth.roles);
|
||||
@ -260,6 +262,8 @@ export const NewRoleModal = ({
|
||||
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),
|
||||
permissions: selectedPermissions.length > 0 ? selectedPermissions : undefined,
|
||||
|
||||
@ -1,302 +1,302 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Modal, FormField, FormSelect, PrimaryButton, SecondaryButton, MultiselectPaginatedSelect } from '@/components/shared';
|
||||
import { moduleService } from '@/services/module-service';
|
||||
// import { useEffect, useState } from 'react';
|
||||
// import type { ReactElement } from 'react';
|
||||
// import { useForm } from 'react-hook-form';
|
||||
// import { zodResolver } from '@hookform/resolvers/zod';
|
||||
// import { z } from 'zod';
|
||||
// import { Modal, FormField, FormSelect, PrimaryButton, SecondaryButton, MultiselectPaginatedSelect } from '@/components/shared';
|
||||
// import { moduleService } from '@/services/module-service';
|
||||
|
||||
// Validation schema - matches backend validation
|
||||
const newTenantSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'name is required')
|
||||
.min(3, 'name must be at least 3 characters')
|
||||
.max(100, 'name must be at most 100 characters'),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1, 'slug is required')
|
||||
.min(3, 'slug must be at least 3 characters')
|
||||
.max(100, 'slug must be at most 100 characters')
|
||||
.regex(/^[a-z0-9-]+$/, 'slug format is invalid'),
|
||||
status: z.enum(['active', 'suspended', 'deleted'], {
|
||||
message: 'Status is required',
|
||||
}),
|
||||
settings: z.any().optional().nullable(),
|
||||
subscription_tier: z.enum(['basic', 'professional', 'enterprise'], {
|
||||
message: 'Invalid subscription tier',
|
||||
}).optional().nullable(),
|
||||
max_users: z.number().int().min(1, 'max_users must be at least 1').optional().nullable(),
|
||||
max_modules: z.number().int().min(1, 'max_modules must be at least 1').optional().nullable(),
|
||||
modules: z.array(z.string().uuid()).optional().nullable(),
|
||||
});
|
||||
// // Validation schema - matches backend validation
|
||||
// const newTenantSchema = z.object({
|
||||
// name: z
|
||||
// .string()
|
||||
// .min(1, 'name is required')
|
||||
// .min(3, 'name must be at least 3 characters')
|
||||
// .max(100, 'name must be at most 100 characters'),
|
||||
// slug: z
|
||||
// .string()
|
||||
// .min(1, 'slug is required')
|
||||
// .min(3, 'slug must be at least 3 characters')
|
||||
// .max(100, 'slug must be at most 100 characters')
|
||||
// .regex(/^[a-z0-9-]+$/, 'slug format is invalid'),
|
||||
// status: z.enum(['active', 'suspended', 'deleted'], {
|
||||
// message: 'Status is required',
|
||||
// }),
|
||||
// settings: z.any().optional().nullable(),
|
||||
// subscription_tier: z.enum(['basic', 'professional', 'enterprise'], {
|
||||
// message: 'Invalid subscription tier',
|
||||
// }).optional().nullable(),
|
||||
// max_users: z.number().int().min(1, 'max_users must be at least 1').optional().nullable(),
|
||||
// max_modules: z.number().int().min(1, 'max_modules must be at least 1').optional().nullable(),
|
||||
// modules: z.array(z.string().uuid()).optional().nullable(),
|
||||
// });
|
||||
|
||||
type NewTenantFormData = z.infer<typeof newTenantSchema>;
|
||||
// type NewTenantFormData = z.infer<typeof newTenantSchema>;
|
||||
|
||||
interface NewTenantModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: NewTenantFormData) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
// interface NewTenantModalProps {
|
||||
// isOpen: boolean;
|
||||
// onClose: () => void;
|
||||
// onSubmit: (data: NewTenantFormData) => Promise<void>;
|
||||
// isLoading?: boolean;
|
||||
// }
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'suspended', label: 'Suspended' },
|
||||
{ value: 'deleted', label: 'Deleted' },
|
||||
];
|
||||
// const statusOptions = [
|
||||
// { value: 'active', label: 'Active' },
|
||||
// { value: 'suspended', label: 'Suspended' },
|
||||
// { value: 'deleted', label: 'Deleted' },
|
||||
// ];
|
||||
|
||||
const subscriptionTierOptions = [
|
||||
{ value: 'basic', label: 'Basic' },
|
||||
{ value: 'professional', label: 'Professional' },
|
||||
{ value: 'enterprise', label: 'Enterprise' },
|
||||
];
|
||||
// const subscriptionTierOptions = [
|
||||
// { value: 'basic', label: 'Basic' },
|
||||
// { value: 'professional', label: 'Professional' },
|
||||
// { value: 'enterprise', label: 'Enterprise' },
|
||||
// ];
|
||||
|
||||
export const NewTenantModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
}: NewTenantModalProps): ReactElement | null => {
|
||||
const [selectedModules, setSelectedModules] = useState<string[]>([]);
|
||||
// export const NewTenantModal = ({
|
||||
// isOpen,
|
||||
// onClose,
|
||||
// onSubmit,
|
||||
// isLoading = false,
|
||||
// }: NewTenantModalProps): ReactElement | null => {
|
||||
// const [selectedModules, setSelectedModules] = useState<string[]>([]);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
reset,
|
||||
setError,
|
||||
clearErrors,
|
||||
formState: { errors },
|
||||
} = useForm<NewTenantFormData>({
|
||||
resolver: zodResolver(newTenantSchema),
|
||||
defaultValues: {
|
||||
status: 'active',
|
||||
settings: null,
|
||||
subscription_tier: null,
|
||||
max_users: null,
|
||||
max_modules: null,
|
||||
modules: [],
|
||||
},
|
||||
});
|
||||
// const {
|
||||
// register,
|
||||
// handleSubmit,
|
||||
// setValue,
|
||||
// watch,
|
||||
// reset,
|
||||
// setError,
|
||||
// clearErrors,
|
||||
// formState: { errors },
|
||||
// } = useForm<NewTenantFormData>({
|
||||
// resolver: zodResolver(newTenantSchema),
|
||||
// defaultValues: {
|
||||
// status: 'active',
|
||||
// settings: null,
|
||||
// subscription_tier: null,
|
||||
// max_users: null,
|
||||
// max_modules: null,
|
||||
// modules: [],
|
||||
// },
|
||||
// });
|
||||
|
||||
const statusValue = watch('status');
|
||||
const subscriptionTierValue = watch('subscription_tier');
|
||||
// const statusValue = watch('status');
|
||||
// const subscriptionTierValue = watch('subscription_tier');
|
||||
|
||||
// Load modules for multiselect
|
||||
const loadModules = async (page: number, limit: number) => {
|
||||
const response = await moduleService.getRunningModules(page, limit);
|
||||
return {
|
||||
options: response.data.map((module) => ({
|
||||
value: module.id,
|
||||
label: module.name,
|
||||
})),
|
||||
pagination: response.pagination,
|
||||
};
|
||||
};
|
||||
// // Load modules for multiselect
|
||||
// const loadModules = async (page: number, limit: number) => {
|
||||
// const response = await moduleService.getRunningModules(page, limit);
|
||||
// return {
|
||||
// options: response.data.map((module) => ({
|
||||
// value: module.id,
|
||||
// label: module.name,
|
||||
// })),
|
||||
// pagination: response.pagination,
|
||||
// };
|
||||
// };
|
||||
|
||||
// Reset form when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
reset({
|
||||
name: '',
|
||||
slug: '',
|
||||
status: 'active',
|
||||
settings: null,
|
||||
subscription_tier: null,
|
||||
max_users: null,
|
||||
max_modules: null,
|
||||
modules: [],
|
||||
});
|
||||
setSelectedModules([]);
|
||||
clearErrors();
|
||||
}
|
||||
}, [isOpen, reset, clearErrors]);
|
||||
// // Reset form when modal closes
|
||||
// useEffect(() => {
|
||||
// if (!isOpen) {
|
||||
// reset({
|
||||
// name: '',
|
||||
// slug: '',
|
||||
// status: 'active',
|
||||
// settings: null,
|
||||
// subscription_tier: null,
|
||||
// max_users: null,
|
||||
// max_modules: null,
|
||||
// modules: [],
|
||||
// });
|
||||
// setSelectedModules([]);
|
||||
// clearErrors();
|
||||
// }
|
||||
// }, [isOpen, reset, clearErrors]);
|
||||
|
||||
const handleFormSubmit = async (data: NewTenantFormData): Promise<void> => {
|
||||
clearErrors();
|
||||
try {
|
||||
const { modules, ...restData } = data;
|
||||
const submitData = {
|
||||
...restData,
|
||||
module_ids: selectedModules.length > 0 ? selectedModules : undefined,
|
||||
};
|
||||
await onSubmit(submitData);
|
||||
} catch (error: any) {
|
||||
// Handle validation errors from API
|
||||
if (error?.response?.data?.details && Array.isArray(error.response.data.details)) {
|
||||
const validationErrors = error.response.data.details;
|
||||
validationErrors.forEach((detail: { path: string; message: string }) => {
|
||||
if (
|
||||
detail.path === 'name' ||
|
||||
detail.path === 'slug' ||
|
||||
detail.path === 'status' ||
|
||||
detail.path === 'settings' ||
|
||||
detail.path === 'subscription_tier' ||
|
||||
detail.path === 'max_users' ||
|
||||
detail.path === 'max_modules' ||
|
||||
detail.path === 'module_ids'
|
||||
) {
|
||||
// Map module_ids error to modules field for display
|
||||
const fieldPath = detail.path === 'module_ids' ? 'modules' : detail.path;
|
||||
setError(fieldPath as keyof NewTenantFormData, {
|
||||
type: 'server',
|
||||
message: detail.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Handle general errors
|
||||
// Check for nested error object with message property
|
||||
const errorObj = error?.response?.data?.error;
|
||||
const errorMessage =
|
||||
(typeof errorObj === 'object' && errorObj !== null && 'message' in errorObj ? errorObj.message : null) ||
|
||||
(typeof errorObj === 'string' ? errorObj : null) ||
|
||||
error?.response?.data?.message ||
|
||||
error?.message ||
|
||||
'Failed to create tenant. Please try again.';
|
||||
setError('root', {
|
||||
type: 'server',
|
||||
message: typeof errorMessage === 'string' ? errorMessage : 'Failed to create tenant. Please try again.',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
// const handleFormSubmit = async (data: NewTenantFormData): Promise<void> => {
|
||||
// clearErrors();
|
||||
// try {
|
||||
// const { modules, ...restData } = data;
|
||||
// const submitData = {
|
||||
// ...restData,
|
||||
// module_ids: selectedModules.length > 0 ? selectedModules : undefined,
|
||||
// };
|
||||
// await onSubmit(submitData);
|
||||
// } catch (error: any) {
|
||||
// // Handle validation errors from API
|
||||
// if (error?.response?.data?.details && Array.isArray(error.response.data.details)) {
|
||||
// const validationErrors = error.response.data.details;
|
||||
// validationErrors.forEach((detail: { path: string; message: string }) => {
|
||||
// if (
|
||||
// detail.path === 'name' ||
|
||||
// detail.path === 'slug' ||
|
||||
// detail.path === 'status' ||
|
||||
// detail.path === 'settings' ||
|
||||
// detail.path === 'subscription_tier' ||
|
||||
// detail.path === 'max_users' ||
|
||||
// detail.path === 'max_modules' ||
|
||||
// detail.path === 'module_ids'
|
||||
// ) {
|
||||
// // Map module_ids error to modules field for display
|
||||
// const fieldPath = detail.path === 'module_ids' ? 'modules' : detail.path;
|
||||
// setError(fieldPath as keyof NewTenantFormData, {
|
||||
// type: 'server',
|
||||
// message: detail.message,
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
// } else {
|
||||
// // Handle general errors
|
||||
// // Check for nested error object with message property
|
||||
// const errorObj = error?.response?.data?.error;
|
||||
// const errorMessage =
|
||||
// (typeof errorObj === 'object' && errorObj !== null && 'message' in errorObj ? errorObj.message : null) ||
|
||||
// (typeof errorObj === 'string' ? errorObj : null) ||
|
||||
// error?.response?.data?.message ||
|
||||
// error?.message ||
|
||||
// 'Failed to create tenant. Please try again.';
|
||||
// setError('root', {
|
||||
// type: 'server',
|
||||
// message: typeof errorMessage === 'string' ? errorMessage : 'Failed to create tenant. Please try again.',
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
// };
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Create New Tenant"
|
||||
description="Add a new organization to the platform"
|
||||
maxWidth="md"
|
||||
footer={
|
||||
<>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2.5 text-sm"
|
||||
>
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={handleSubmit(handleFormSubmit)}
|
||||
disabled={isLoading}
|
||||
size="default"
|
||||
className="px-4 py-2.5 text-sm"
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create Tenant'}
|
||||
</PrimaryButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-5">
|
||||
{/* General Error Display */}
|
||||
{errors.root && (
|
||||
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md mb-4">
|
||||
<p className="text-sm text-[#ef4444]">{errors.root.message}</p>
|
||||
</div>
|
||||
)}
|
||||
// return (
|
||||
// <Modal
|
||||
// isOpen={isOpen}
|
||||
// onClose={onClose}
|
||||
// title="Create New Tenant"
|
||||
// description="Add a new organization to the platform"
|
||||
// maxWidth="md"
|
||||
// footer={
|
||||
// <>
|
||||
// <SecondaryButton
|
||||
// type="button"
|
||||
// onClick={onClose}
|
||||
// disabled={isLoading}
|
||||
// className="px-4 py-2.5 text-sm"
|
||||
// >
|
||||
// Cancel
|
||||
// </SecondaryButton>
|
||||
// <PrimaryButton
|
||||
// type="button"
|
||||
// onClick={handleSubmit(handleFormSubmit)}
|
||||
// disabled={isLoading}
|
||||
// size="default"
|
||||
// className="px-4 py-2.5 text-sm"
|
||||
// >
|
||||
// {isLoading ? 'Creating...' : 'Create Tenant'}
|
||||
// </PrimaryButton>
|
||||
// </>
|
||||
// }
|
||||
// >
|
||||
// <form onSubmit={handleSubmit(handleFormSubmit)} className="p-5">
|
||||
// {/* General Error Display */}
|
||||
// {errors.root && (
|
||||
// <div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md mb-4">
|
||||
// <p className="text-sm text-[#ef4444]">{errors.root.message}</p>
|
||||
// </div>
|
||||
// )}
|
||||
|
||||
<div className="flex flex-col gap-0">
|
||||
{/* Tenant Name */}
|
||||
<FormField
|
||||
label="Tenant Name"
|
||||
required
|
||||
placeholder="Enter tenant name"
|
||||
error={errors.name?.message}
|
||||
{...register('name')}
|
||||
/>
|
||||
// <div className="flex flex-col gap-0">
|
||||
// {/* Tenant Name */}
|
||||
// <FormField
|
||||
// label="Tenant Name"
|
||||
// required
|
||||
// placeholder="Enter tenant name"
|
||||
// error={errors.name?.message}
|
||||
// {...register('name')}
|
||||
// />
|
||||
|
||||
{/* Slug */}
|
||||
<FormField
|
||||
label="Slug"
|
||||
required
|
||||
placeholder="Enter slug (lowercase, numbers, hyphens only)"
|
||||
error={errors.slug?.message}
|
||||
{...register('slug')}
|
||||
/>
|
||||
// {/* Slug */}
|
||||
// <FormField
|
||||
// label="Slug"
|
||||
// required
|
||||
// placeholder="Enter slug (lowercase, numbers, hyphens only)"
|
||||
// error={errors.slug?.message}
|
||||
// {...register('slug')}
|
||||
// />
|
||||
|
||||
{/* Status and Subscription Tier Row */}
|
||||
<div className="flex gap-5">
|
||||
<div className="flex-1">
|
||||
<FormSelect
|
||||
label="Status"
|
||||
required
|
||||
placeholder="Select Status"
|
||||
options={statusOptions}
|
||||
value={statusValue}
|
||||
onValueChange={(value) => setValue('status', value as 'active' | 'suspended' | 'deleted')}
|
||||
error={errors.status?.message}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<FormSelect
|
||||
label="Subscription Tier"
|
||||
placeholder="Select Subscription"
|
||||
options={subscriptionTierOptions}
|
||||
value={subscriptionTierValue || ''}
|
||||
onValueChange={(value) => setValue('subscription_tier', value === '' ? null : value as 'basic' | 'professional' | 'enterprise')}
|
||||
error={errors.subscription_tier?.message}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
// {/* Status and Subscription Tier Row */}
|
||||
// <div className="flex gap-5">
|
||||
// <div className="flex-1">
|
||||
// <FormSelect
|
||||
// label="Status"
|
||||
// required
|
||||
// placeholder="Select Status"
|
||||
// options={statusOptions}
|
||||
// value={statusValue}
|
||||
// onValueChange={(value) => setValue('status', value as 'active' | 'suspended' | 'deleted')}
|
||||
// error={errors.status?.message}
|
||||
// />
|
||||
// </div>
|
||||
// <div className="flex-1">
|
||||
// <FormSelect
|
||||
// label="Subscription Tier"
|
||||
// placeholder="Select Subscription"
|
||||
// options={subscriptionTierOptions}
|
||||
// value={subscriptionTierValue || ''}
|
||||
// onValueChange={(value) => setValue('subscription_tier', value === '' ? null : value as 'basic' | 'professional' | 'enterprise')}
|
||||
// error={errors.subscription_tier?.message}
|
||||
// />
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
{/* Max Users and Max Modules Row */}
|
||||
<div className="flex gap-5">
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
label="Max Users"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
placeholder="Enter number"
|
||||
error={errors.max_users?.message}
|
||||
{...register('max_users', {
|
||||
setValueAs: (value) => {
|
||||
if (value === '' || value === null || value === undefined) return null;
|
||||
const num = Number(value);
|
||||
return isNaN(num) ? null : num;
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
label="Max Modules"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
placeholder="Enter number"
|
||||
error={errors.max_modules?.message}
|
||||
{...register('max_modules', {
|
||||
setValueAs: (value) => {
|
||||
if (value === '' || value === null || value === undefined) return null;
|
||||
const num = Number(value);
|
||||
return isNaN(num) ? null : num;
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
// {/* Max Users and Max Modules Row */}
|
||||
// <div className="flex gap-5">
|
||||
// <div className="flex-1">
|
||||
// <FormField
|
||||
// label="Max Users"
|
||||
// type="number"
|
||||
// min="1"
|
||||
// step="1"
|
||||
// placeholder="Enter number"
|
||||
// error={errors.max_users?.message}
|
||||
// {...register('max_users', {
|
||||
// setValueAs: (value) => {
|
||||
// if (value === '' || value === null || value === undefined) return null;
|
||||
// const num = Number(value);
|
||||
// return isNaN(num) ? null : num;
|
||||
// },
|
||||
// })}
|
||||
// />
|
||||
// </div>
|
||||
// <div className="flex-1">
|
||||
// <FormField
|
||||
// label="Max Modules"
|
||||
// type="number"
|
||||
// min="1"
|
||||
// step="1"
|
||||
// placeholder="Enter number"
|
||||
// error={errors.max_modules?.message}
|
||||
// {...register('max_modules', {
|
||||
// setValueAs: (value) => {
|
||||
// if (value === '' || value === null || value === undefined) return null;
|
||||
// const num = Number(value);
|
||||
// return isNaN(num) ? null : num;
|
||||
// },
|
||||
// })}
|
||||
// />
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
{/* Modules Multiselect */}
|
||||
<MultiselectPaginatedSelect
|
||||
label="Modules"
|
||||
placeholder="Select modules"
|
||||
value={selectedModules}
|
||||
onValueChange={(values) => {
|
||||
setSelectedModules(values);
|
||||
setValue('modules', values.length > 0 ? values : []);
|
||||
}}
|
||||
onLoadOptions={loadModules}
|
||||
error={errors.modules?.message}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
// {/* Modules Multiselect */}
|
||||
// <MultiselectPaginatedSelect
|
||||
// label="Modules"
|
||||
// placeholder="Select modules"
|
||||
// value={selectedModules}
|
||||
// onValueChange={(values) => {
|
||||
// setSelectedModules(values);
|
||||
// setValue('modules', values.length > 0 ? values : []);
|
||||
// }}
|
||||
// onLoadOptions={loadModules}
|
||||
// error={errors.modules?.message}
|
||||
// />
|
||||
// </div>
|
||||
// </form>
|
||||
// </Modal>
|
||||
// );
|
||||
// };
|
||||
|
||||
@ -43,6 +43,7 @@ interface NewUserModalProps {
|
||||
onClose: () => void;
|
||||
onSubmit: (data: Omit<NewUserFormData, 'confirmPassword'>) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
defaultTenantId?: string; // If provided, automatically set tenant_id and hide tenant field
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
@ -56,6 +57,7 @@ export const NewUserModal = ({
|
||||
onClose,
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
defaultTenantId,
|
||||
}: NewUserModalProps): ReactElement | null => {
|
||||
const {
|
||||
register,
|
||||
@ -80,6 +82,13 @@ export const NewUserModal = ({
|
||||
const tenantIdValue = watch('tenant_id');
|
||||
const roleIdValue = watch('role_id');
|
||||
|
||||
// Set default tenant_id when modal opens or defaultTenantId changes
|
||||
useEffect(() => {
|
||||
if (isOpen && defaultTenantId) {
|
||||
setValue('tenant_id', defaultTenantId, { shouldValidate: true });
|
||||
}
|
||||
}, [isOpen, defaultTenantId, setValue]);
|
||||
|
||||
// Reset form when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
@ -91,12 +100,12 @@ export const NewUserModal = ({
|
||||
last_name: '',
|
||||
status: 'active',
|
||||
auth_provider: 'local',
|
||||
tenant_id: '',
|
||||
tenant_id: defaultTenantId || '',
|
||||
role_id: '',
|
||||
});
|
||||
clearErrors();
|
||||
}
|
||||
}, [isOpen, reset, clearErrors]);
|
||||
}, [isOpen, reset, clearErrors, defaultTenantId]);
|
||||
|
||||
// Load tenants for dropdown
|
||||
const loadTenants = async (page: number, limit: number) => {
|
||||
@ -112,7 +121,10 @@ export const NewUserModal = ({
|
||||
|
||||
// Load roles for dropdown
|
||||
const loadRoles = async (page: number, limit: number) => {
|
||||
const response = await roleService.getAll(page, limit);
|
||||
// If defaultTenantId is provided, filter roles by tenant_id
|
||||
const response = defaultTenantId
|
||||
? await roleService.getByTenant(defaultTenantId, page, limit)
|
||||
: await roleService.getAll(page, limit);
|
||||
return {
|
||||
options: response.data.map((role) => ({
|
||||
value: role.id,
|
||||
@ -126,6 +138,10 @@ export const NewUserModal = ({
|
||||
clearErrors();
|
||||
try {
|
||||
const { confirmPassword, ...submitData } = data;
|
||||
// Ensure tenant_id is set from defaultTenantId if provided
|
||||
if (defaultTenantId) {
|
||||
submitData.tenant_id = defaultTenantId;
|
||||
}
|
||||
await onSubmit(submitData);
|
||||
} catch (error: any) {
|
||||
// Handle validation errors from API
|
||||
@ -255,7 +271,8 @@ export const NewUserModal = ({
|
||||
</div>
|
||||
|
||||
{/* Tenant and Role Row */}
|
||||
<div className="grid grid-cols-2 gap-5 pb-4">
|
||||
<div className={`grid ${defaultTenantId ? 'grid-cols-1' : 'grid-cols-2'} gap-5 pb-4`}>
|
||||
{!defaultTenantId && (
|
||||
<PaginatedSelect
|
||||
label="Assign Tenant"
|
||||
required
|
||||
@ -265,6 +282,7 @@ export const NewUserModal = ({
|
||||
onLoadOptions={loadTenants}
|
||||
error={errors.tenant_id?.message}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PaginatedSelect
|
||||
label="Assign Role"
|
||||
|
||||
545
src/components/shared/RolesTable.tsx
Normal file
545
src/components/shared/RolesTable.tsx
Normal file
@ -0,0 +1,545 @@
|
||||
import { useState, useEffect, type ReactElement } from 'react';
|
||||
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';
|
||||
import { formatDate } from '@/utils/format-date';
|
||||
|
||||
// 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';
|
||||
}
|
||||
};
|
||||
|
||||
interface RolesTableProps {
|
||||
tenantId?: string | null; // If provided, fetch roles for this tenant only
|
||||
showHeader?: boolean; // Show header with title and actions (default: true)
|
||||
compact?: boolean; // Compact mode for tabs (default: false)
|
||||
}
|
||||
|
||||
export const RolesTable = ({ tenantId, showHeader = true, compact = false }: RolesTableProps): 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>(compact ? 10 : 20);
|
||||
const [pagination, setPagination] = useState<{
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasMore: boolean;
|
||||
}>({
|
||||
page: 1,
|
||||
limit: compact ? 10 : 20,
|
||||
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 = tenantId
|
||||
? await roleService.getByTenant(tenantId, page, itemsPerPage, scope, sortBy)
|
||||
: 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, tenantId]);
|
||||
|
||||
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);
|
||||
setSelectedRoleName('');
|
||||
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; // Let the modal handle the error display
|
||||
} 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>
|
||||
);
|
||||
|
||||
if (compact) {
|
||||
// Compact mode for tabs
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-[#0f1724]">Roles</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<FilterDropdown
|
||||
label="Scope"
|
||||
options={[
|
||||
{ value: '', label: 'All Scope' },
|
||||
{ value: 'platform', label: 'Platform' },
|
||||
{ value: 'tenant', label: 'Tenant' },
|
||||
{ value: 'module', label: 'Module' },
|
||||
]}
|
||||
value={scopeFilter || ''}
|
||||
onChange={(value) => {
|
||||
setScopeFilter(Array.isArray(value) ? null : value || null);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
placeholder="Filter by scope"
|
||||
/>
|
||||
<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>
|
||||
<DataTable
|
||||
data={roles}
|
||||
columns={columns}
|
||||
keyExtractor={(role) => role.id}
|
||||
mobileCardRenderer={mobileCardRenderer}
|
||||
emptyMessage="No roles found"
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
/>
|
||||
{pagination.totalPages > 1 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={pagination.totalPages}
|
||||
totalItems={pagination.total}
|
||||
limit={limit}
|
||||
onPageChange={(page: number) => {
|
||||
setCurrentPage(page);
|
||||
}}
|
||||
onLimitChange={(newLimit: number) => {
|
||||
setLimit(newLimit);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
<NewRoleModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSubmit={handleCreateRole}
|
||||
isLoading={isCreating}
|
||||
defaultTenantId={tenantId || undefined}
|
||||
/>
|
||||
|
||||
<ViewRoleModal
|
||||
isOpen={viewModalOpen}
|
||||
onClose={() => {
|
||||
setViewModalOpen(false);
|
||||
setSelectedRoleId(null);
|
||||
}}
|
||||
roleId={selectedRoleId}
|
||||
onLoadRole={loadRole}
|
||||
/>
|
||||
|
||||
<EditRoleModal
|
||||
isOpen={editModalOpen}
|
||||
onClose={() => {
|
||||
setEditModalOpen(false);
|
||||
setSelectedRoleId(null);
|
||||
setSelectedRoleName('');
|
||||
}}
|
||||
roleId={selectedRoleId}
|
||||
onLoadRole={loadRole}
|
||||
onSubmit={handleUpdateRole}
|
||||
isLoading={isUpdating}
|
||||
defaultTenantId={tenantId || undefined}
|
||||
/>
|
||||
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Full mode with header
|
||||
return (
|
||||
<>
|
||||
{/* 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 */}
|
||||
{showHeader && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Data 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>
|
||||
|
||||
{/* Modals */}
|
||||
<NewRoleModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSubmit={handleCreateRole}
|
||||
isLoading={isCreating}
|
||||
defaultTenantId={tenantId || undefined}
|
||||
/>
|
||||
|
||||
<ViewRoleModal
|
||||
isOpen={viewModalOpen}
|
||||
onClose={() => {
|
||||
setViewModalOpen(false);
|
||||
setSelectedRoleId(null);
|
||||
}}
|
||||
roleId={selectedRoleId}
|
||||
onLoadRole={loadRole}
|
||||
/>
|
||||
|
||||
<EditRoleModal
|
||||
isOpen={editModalOpen}
|
||||
onClose={() => {
|
||||
setEditModalOpen(false);
|
||||
setSelectedRoleId(null);
|
||||
setSelectedRoleName('');
|
||||
}}
|
||||
roleId={selectedRoleId}
|
||||
onLoadRole={loadRole}
|
||||
onSubmit={handleUpdateRole}
|
||||
isLoading={isUpdating}
|
||||
defaultTenantId={tenantId || undefined}
|
||||
/>
|
||||
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
582
src/components/shared/UsersTable.tsx
Normal file
582
src/components/shared/UsersTable.tsx
Normal file
@ -0,0 +1,582 @@
|
||||
import { useState, useEffect, type ReactElement } from 'react';
|
||||
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';
|
||||
import { formatDate } from '@/utils/format-date';
|
||||
|
||||
// Helper function to get user initials
|
||||
const getUserInitials = (firstName: string, lastName: string): string => {
|
||||
return `${firstName[0]}${lastName[0]}`.toUpperCase();
|
||||
};
|
||||
|
||||
// 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';
|
||||
}
|
||||
};
|
||||
|
||||
interface UsersTableProps {
|
||||
tenantId?: string | null; // If provided, fetch users for this tenant only
|
||||
showHeader?: boolean; // Show header with title and actions (default: true)
|
||||
compact?: boolean; // Compact mode for tabs (default: false)
|
||||
}
|
||||
|
||||
export const UsersTable = ({ tenantId, showHeader = true, compact = false }: UsersTableProps): 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>(compact ? 10 : 5);
|
||||
const [pagination, setPagination] = useState<{
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasMore: boolean;
|
||||
}>({
|
||||
page: 1,
|
||||
limit: compact ? 10 : 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 = tenantId
|
||||
? await userService.getByTenant(tenantId, page, itemsPerPage, status, sortBy)
|
||||
: 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, tenantId]);
|
||||
|
||||
const handleCreateUser = async (data: {
|
||||
email: string;
|
||||
password: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
status: 'active' | 'suspended' | 'deleted';
|
||||
auth_provider: 'local';
|
||||
tenant_id: string;
|
||||
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 (
|
||||
userId: string,
|
||||
data: {
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
status: 'active' | 'suspended' | 'deleted';
|
||||
auth_provider?: string;
|
||||
tenant_id: string;
|
||||
role_id: string;
|
||||
}
|
||||
): Promise<void> => {
|
||||
try {
|
||||
setIsUpdating(true);
|
||||
const response = await userService.update(userId, 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);
|
||||
setSelectedUserName('');
|
||||
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; // Let the modal handle the error display
|
||||
} 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>
|
||||
);
|
||||
|
||||
if (compact) {
|
||||
// Compact mode for tabs
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-[#0f1724]">Users</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<FilterDropdown
|
||||
label="Status"
|
||||
options={[
|
||||
{ value: '', label: 'All Status' },
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'suspended', label: 'Suspended' },
|
||||
{ value: 'deleted', label: 'Deleted' },
|
||||
]}
|
||||
value={statusFilter || ''}
|
||||
onChange={(value) => {
|
||||
setStatusFilter(Array.isArray(value) ? null : value || null);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
placeholder="Filter by status"
|
||||
/>
|
||||
<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>
|
||||
<DataTable
|
||||
data={users}
|
||||
columns={columns}
|
||||
keyExtractor={(user) => user.id}
|
||||
mobileCardRenderer={mobileCardRenderer}
|
||||
emptyMessage="No users found"
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
/>
|
||||
{pagination.totalPages > 1 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={pagination.totalPages}
|
||||
totalItems={pagination.total}
|
||||
limit={limit}
|
||||
onPageChange={(page: number) => {
|
||||
setCurrentPage(page);
|
||||
}}
|
||||
onLimitChange={(newLimit: number) => {
|
||||
setLimit(newLimit);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
<NewUserModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSubmit={handleCreateUser}
|
||||
isLoading={isCreating}
|
||||
defaultTenantId={tenantId || undefined}
|
||||
/>
|
||||
|
||||
<ViewUserModal
|
||||
isOpen={viewModalOpen}
|
||||
onClose={() => {
|
||||
setViewModalOpen(false);
|
||||
setSelectedUserId(null);
|
||||
}}
|
||||
userId={selectedUserId}
|
||||
onLoadUser={loadUser}
|
||||
/>
|
||||
|
||||
<EditUserModal
|
||||
isOpen={editModalOpen}
|
||||
onClose={() => {
|
||||
setEditModalOpen(false);
|
||||
setSelectedUserId(null);
|
||||
setSelectedUserName('');
|
||||
}}
|
||||
userId={selectedUserId}
|
||||
onLoadUser={loadUser}
|
||||
onSubmit={handleUpdateUser}
|
||||
isLoading={isUpdating}
|
||||
defaultTenantId={tenantId || undefined}
|
||||
/>
|
||||
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Full mode with header
|
||||
return (
|
||||
<>
|
||||
{/* 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 */}
|
||||
{showHeader && (
|
||||
<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);
|
||||
}}
|
||||
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);
|
||||
}}
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
<NewUserModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSubmit={handleCreateUser}
|
||||
isLoading={isCreating}
|
||||
defaultTenantId={tenantId || undefined}
|
||||
/>
|
||||
|
||||
<ViewUserModal
|
||||
isOpen={viewModalOpen}
|
||||
onClose={() => {
|
||||
setViewModalOpen(false);
|
||||
setSelectedUserId(null);
|
||||
}}
|
||||
userId={selectedUserId}
|
||||
onLoadUser={loadUser}
|
||||
/>
|
||||
|
||||
<EditUserModal
|
||||
isOpen={editModalOpen}
|
||||
onClose={() => {
|
||||
setEditModalOpen(false);
|
||||
setSelectedUserId(null);
|
||||
setSelectedUserName('');
|
||||
}}
|
||||
userId={selectedUserId}
|
||||
onLoadUser={loadUser}
|
||||
onSubmit={handleUpdateUser}
|
||||
isLoading={isUpdating}
|
||||
/>
|
||||
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -12,7 +12,7 @@ export { DataTable } from './DataTable';
|
||||
export type { Column } from './DataTable';
|
||||
export { Pagination } from './Pagination';
|
||||
export { FilterDropdown } from './FilterDropdown';
|
||||
export { NewTenantModal } from './NewTenantModal';
|
||||
// export { NewTenantModal } from './NewTenantModal';
|
||||
export { ViewTenantModal } from './ViewTenantModal';
|
||||
export { EditTenantModal } from './EditTenantModal';
|
||||
export { DeleteConfirmationModal } from './DeleteConfirmationModal';
|
||||
@ -27,3 +27,5 @@ 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';
|
||||
@ -66,7 +66,7 @@ export const StatsGrid = () => {
|
||||
: 'gray',
|
||||
},
|
||||
},
|
||||
];
|
||||
];
|
||||
|
||||
setStatsData(mappedStats);
|
||||
} catch (err) {
|
||||
|
||||
761
src/pages/CreateTenantWizard.tsx
Normal file
761
src/pages/CreateTenantWizard.tsx
Normal file
@ -0,0 +1,761 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Layout } from '@/components/layout/Layout';
|
||||
import { FormField, FormSelect, PrimaryButton, SecondaryButton, MultiselectPaginatedSelect } from '@/components/shared';
|
||||
import { tenantService } from '@/services/tenant-service';
|
||||
import { moduleService } from '@/services/module-service';
|
||||
import { showToast } from '@/utils/toast';
|
||||
import { ChevronRight, ChevronLeft } from 'lucide-react';
|
||||
|
||||
// Step 1: Tenant Details Schema - matches NewTenantModal
|
||||
const tenantDetailsSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'name is required')
|
||||
.min(3, 'name must be at least 3 characters')
|
||||
.max(100, 'name must be at most 100 characters'),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1, 'slug is required')
|
||||
.min(3, 'slug must be at least 3 characters')
|
||||
.max(100, 'slug must be at most 100 characters')
|
||||
.regex(/^[a-z0-9-]+$/, 'slug format is invalid'),
|
||||
domain: z.string().optional().nullable(),
|
||||
status: z.enum(['active', 'suspended', 'deleted'], {
|
||||
message: 'Status is required',
|
||||
}),
|
||||
subscription_tier: z.enum(['basic', 'professional', 'enterprise'], {
|
||||
message: 'Invalid subscription tier',
|
||||
}).optional().nullable(),
|
||||
max_users: z.number().int().min(1, 'max_users must be at least 1').optional().nullable(),
|
||||
max_modules: z.number().int().min(1, 'max_modules must be at least 1').optional().nullable(),
|
||||
modules: z.array(z.string().uuid()).optional().nullable(),
|
||||
});
|
||||
|
||||
// Step 2: Contact Details Schema - user creation + organization address
|
||||
const contactDetailsSchema = z
|
||||
.object({
|
||||
email: z.string().min(1, 'Email is required').email('Please enter a valid email address'),
|
||||
password: z.string().min(1, 'Password is required').min(6, 'Password must be at least 6 characters'),
|
||||
confirmPassword: z.string().min(1, 'Confirm password is required'),
|
||||
first_name: z.string().min(1, 'First name is required'),
|
||||
last_name: z.string().min(1, 'Last name is required'),
|
||||
contact_phone: z.string().optional().nullable(),
|
||||
address_line1: z.string().min(1, 'Address is required'),
|
||||
address_line2: z.string().optional().nullable(),
|
||||
city: z.string().min(1, 'City is required'),
|
||||
state: z.string().min(1, 'State is required'),
|
||||
postal_code: z.string().min(1, 'Postal code is required'),
|
||||
country: z.string().min(1, 'Country is required'),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
// Step 3: Settings Schema
|
||||
const settingsSchema = z.object({
|
||||
enable_sso: z.boolean(),
|
||||
enable_2fa: z.boolean(),
|
||||
});
|
||||
|
||||
type TenantDetailsForm = z.infer<typeof tenantDetailsSchema>;
|
||||
type ContactDetailsForm = z.infer<typeof contactDetailsSchema>;
|
||||
type SettingsForm = z.infer<typeof settingsSchema>;
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'suspended', label: 'Suspended' },
|
||||
{ value: 'deleted', label: 'Deleted' },
|
||||
];
|
||||
|
||||
const subscriptionTierOptions = [
|
||||
{ value: 'basic', label: 'Basic' },
|
||||
{ value: 'professional', label: 'Professional' },
|
||||
{ value: 'enterprise', label: 'Enterprise' },
|
||||
];
|
||||
|
||||
// Helper function to get base URL without protocol
|
||||
const getBaseUrlWithoutProtocol = (): string => {
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1';
|
||||
// Remove protocol (http:// or https://)
|
||||
return apiBaseUrl.replace(/^https?:\/\//, '');
|
||||
};
|
||||
|
||||
const CreateTenantWizard = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const [currentStep, setCurrentStep] = useState<number>(1);
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
|
||||
const [selectedModules, setSelectedModules] = useState<string[]>([]);
|
||||
const [initialModuleOptions, setInitialModuleOptions] = useState<Array<{ value: string; label: string }>>([]);
|
||||
|
||||
// Form instances for each step
|
||||
const tenantDetailsForm = useForm<TenantDetailsForm>({
|
||||
resolver: zodResolver(tenantDetailsSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
slug: '',
|
||||
domain: '',
|
||||
status: 'active',
|
||||
subscription_tier: null,
|
||||
max_users: null,
|
||||
max_modules: null,
|
||||
modules: [],
|
||||
},
|
||||
});
|
||||
|
||||
// Load modules for multiselect
|
||||
const loadModules = async (page: number, limit: number) => {
|
||||
const response = await moduleService.getRunningModules(page, limit);
|
||||
return {
|
||||
options: response.data.map((module) => ({
|
||||
value: module.id,
|
||||
label: module.name,
|
||||
})),
|
||||
pagination: response.pagination,
|
||||
};
|
||||
};
|
||||
|
||||
const contactDetailsForm = useForm<ContactDetailsForm>({
|
||||
resolver: zodResolver(contactDetailsSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
contact_phone: '',
|
||||
address_line1: '',
|
||||
address_line2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postal_code: '',
|
||||
country: '',
|
||||
},
|
||||
});
|
||||
|
||||
const settingsForm = useForm<SettingsForm>({
|
||||
resolver: zodResolver(settingsSchema),
|
||||
defaultValues: {
|
||||
enable_sso: false,
|
||||
enable_2fa: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-generate slug and domain from name
|
||||
const nameValue = tenantDetailsForm.watch('name');
|
||||
const baseUrlWithoutProtocol = getBaseUrlWithoutProtocol();
|
||||
const previousNameRef = useRef<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (nameValue) {
|
||||
const slug = nameValue
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
tenantDetailsForm.setValue('slug', slug, { shouldValidate: true });
|
||||
|
||||
// Auto-generate domain when tenant name changes (like slug)
|
||||
// Always update domain when name changes, similar to slug behavior
|
||||
if (nameValue !== previousNameRef.current) {
|
||||
const autoGeneratedDomain = `${slug}.${baseUrlWithoutProtocol}`;
|
||||
tenantDetailsForm.setValue('domain', autoGeneratedDomain, { shouldValidate: false });
|
||||
previousNameRef.current = nameValue;
|
||||
}
|
||||
} else if (!nameValue && previousNameRef.current) {
|
||||
// Clear domain when name is cleared
|
||||
tenantDetailsForm.setValue('domain', '', { shouldValidate: false });
|
||||
previousNameRef.current = '';
|
||||
}
|
||||
}, [nameValue, tenantDetailsForm, baseUrlWithoutProtocol]);
|
||||
|
||||
const handleNext = async (): Promise<void> => {
|
||||
if (currentStep === 1) {
|
||||
const isValid = await tenantDetailsForm.trigger();
|
||||
if (isValid) {
|
||||
// Store selected modules and their options for restoration when going back
|
||||
const modules = tenantDetailsForm.getValues('modules') || [];
|
||||
if (modules.length > 0 && initialModuleOptions.length === 0) {
|
||||
// Load module names for selected modules
|
||||
try {
|
||||
const moduleOptionsPromises = modules.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 }>;
|
||||
setInitialModuleOptions(moduleOptions);
|
||||
} catch (err) {
|
||||
console.warn('Failed to load module names:', err);
|
||||
}
|
||||
}
|
||||
setCurrentStep(2);
|
||||
}
|
||||
} else if (currentStep === 2) {
|
||||
const isValid = await contactDetailsForm.trigger();
|
||||
if (isValid) {
|
||||
setCurrentStep(3);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = (): void => {
|
||||
if (currentStep > 1) {
|
||||
// When going back to step 1, restore selected modules and their options
|
||||
if (currentStep === 2) {
|
||||
const modules = tenantDetailsForm.getValues('modules') || [];
|
||||
setSelectedModules(modules);
|
||||
// Restore initial module options if we have selected modules
|
||||
if (modules.length > 0 && initialModuleOptions.length === 0) {
|
||||
// Load module names for selected modules
|
||||
const loadModuleOptions = async () => {
|
||||
try {
|
||||
const moduleOptionsPromises = modules.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 }>;
|
||||
setInitialModuleOptions(moduleOptions);
|
||||
} catch (err) {
|
||||
console.warn('Failed to load module names:', err);
|
||||
}
|
||||
};
|
||||
loadModuleOptions();
|
||||
}
|
||||
}
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
const isValid = await settingsForm.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const tenantDetails = tenantDetailsForm.getValues();
|
||||
const contactDetails = contactDetailsForm.getValues();
|
||||
const settings = settingsForm.getValues();
|
||||
|
||||
// Combine all data for tenant creation - matches NewTenantModal structure
|
||||
const { modules, ...restTenantDetails } = tenantDetails;
|
||||
// Extract confirmPassword from contactDetails (not needed in API call)
|
||||
const { confirmPassword, ...contactData } = contactDetails;
|
||||
|
||||
const tenantData = {
|
||||
...restTenantDetails,
|
||||
module_ids: selectedModules.length > 0 ? selectedModules : undefined,
|
||||
settings: {
|
||||
...settings,
|
||||
contact: contactData, // Include first_name, last_name, email, password
|
||||
},
|
||||
};
|
||||
|
||||
const response = await tenantService.create(tenantData);
|
||||
const message = response.message || 'Tenant created successfully';
|
||||
showToast.success(message);
|
||||
navigate('/tenants');
|
||||
} catch (err: any) {
|
||||
// Handle validation errors from API - same as NewTenantModal
|
||||
if (err?.response?.data?.details && Array.isArray(err.response.data.details)) {
|
||||
const validationErrors = err.response.data.details;
|
||||
validationErrors.forEach((detail: { path: string; message: string }) => {
|
||||
// Handle tenant details errors
|
||||
if (
|
||||
detail.path === 'name' ||
|
||||
detail.path === 'slug' ||
|
||||
detail.path === 'status' ||
|
||||
detail.path === 'subscription_tier' ||
|
||||
detail.path === 'max_users' ||
|
||||
detail.path === 'max_modules' ||
|
||||
detail.path === 'module_ids'
|
||||
) {
|
||||
const fieldPath = detail.path === 'module_ids' ? 'modules' : detail.path;
|
||||
tenantDetailsForm.setError(fieldPath as keyof TenantDetailsForm, {
|
||||
type: 'server',
|
||||
message: detail.message,
|
||||
});
|
||||
}
|
||||
// Handle contact details errors
|
||||
else if (
|
||||
detail.path === 'email' ||
|
||||
detail.path === 'password' ||
|
||||
detail.path === 'first_name' ||
|
||||
detail.path === 'last_name'
|
||||
) {
|
||||
contactDetailsForm.setError(detail.path as keyof ContactDetailsForm, {
|
||||
type: 'server',
|
||||
message: detail.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const errorObj = err?.response?.data?.error;
|
||||
const errorMessage =
|
||||
(typeof errorObj === 'object' && errorObj !== null && 'message' in errorObj ? errorObj.message : null) ||
|
||||
(typeof errorObj === 'string' ? errorObj : null) ||
|
||||
err?.response?.data?.message ||
|
||||
err?.message ||
|
||||
'Failed to create tenant. Please try again.';
|
||||
tenantDetailsForm.setError('root', {
|
||||
type: 'server',
|
||||
message: typeof errorMessage === 'string' ? errorMessage : 'Failed to create tenant. Please try again.',
|
||||
});
|
||||
showToast.error(typeof errorMessage === 'string' ? errorMessage : 'Failed to create tenant. Please try again.');
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{
|
||||
number: 1,
|
||||
title: 'Tenant Details',
|
||||
description: 'Basic organization information',
|
||||
isActive: currentStep === 1,
|
||||
isCompleted: currentStep > 1,
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
title: 'Contact Details',
|
||||
description: 'Primary contact & address',
|
||||
isActive: currentStep === 2,
|
||||
isCompleted: currentStep > 2,
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
title: 'Settings',
|
||||
description: 'Usage limits & security',
|
||||
isActive: currentStep === 3,
|
||||
isCompleted: false,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Layout
|
||||
currentPage="Create Tenant"
|
||||
breadcrumbs={[
|
||||
{ label: 'QAssure', path: '/dashboard' },
|
||||
{ label: 'Tenant Management', path: '/tenants' },
|
||||
{ label: 'Create Tenant' },
|
||||
]}
|
||||
pageHeader={{
|
||||
title: 'Create New Tenant',
|
||||
description: 'Follow the steps to onboard a new Tenant.',
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
{/* Steps Sidebar */}
|
||||
<div className="w-full lg:w-[260px] bg-white border border-[rgba(0,0,0,0.08)] rounded-lg p-5">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-xs font-medium text-[#6b7280] uppercase tracking-wide">Steps</h3>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.number}
|
||||
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
|
||||
? '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]'
|
||||
}`}
|
||||
>
|
||||
{step.title}
|
||||
</div>
|
||||
<div className="text-xs text-[#6b7280]">{step.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 bg-white border border-[rgba(0,0,0,0.08)] rounded-lg p-4">
|
||||
{/* Step 1: Tenant Details */}
|
||||
{currentStep === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div className="pb-4 border-b border-[rgba(0,0,0,0.08)]">
|
||||
<h2 className="text-lg font-semibold text-[#0f1724]">Tenant Details</h2>
|
||||
<p className="text-sm text-[#6b7280] mt-1">
|
||||
Basic information for the new organization.
|
||||
</p>
|
||||
</div>
|
||||
{/* General Error Display */}
|
||||
{tenantDetailsForm.formState.errors.root && (
|
||||
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md mb-4">
|
||||
<p className="text-sm text-[#ef4444]">{tenantDetailsForm.formState.errors.root.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
label="Tenant Name"
|
||||
required
|
||||
placeholder="Enter tenant name"
|
||||
error={tenantDetailsForm.formState.errors.name?.message}
|
||||
{...tenantDetailsForm.register('name')}
|
||||
/>
|
||||
<FormField
|
||||
label="Slug"
|
||||
required
|
||||
placeholder="Enter slug (lowercase, numbers, hyphens only)"
|
||||
error={tenantDetailsForm.formState.errors.slug?.message}
|
||||
{...tenantDetailsForm.register('slug')}
|
||||
/>
|
||||
<FormField
|
||||
label="Domain"
|
||||
placeholder="Auto-generated from tenant name"
|
||||
error={tenantDetailsForm.formState.errors.domain?.message}
|
||||
{...tenantDetailsForm.register('domain')}
|
||||
/>
|
||||
{/* Status and Subscription Tier Row */}
|
||||
<div className="flex gap-5">
|
||||
<div className="flex-1">
|
||||
<FormSelect
|
||||
label="Status"
|
||||
required
|
||||
placeholder="Select Status"
|
||||
options={statusOptions}
|
||||
value={tenantDetailsForm.watch('status')}
|
||||
onValueChange={(value) =>
|
||||
tenantDetailsForm.setValue('status', value as 'active' | 'suspended' | 'deleted')
|
||||
}
|
||||
error={tenantDetailsForm.formState.errors.status?.message}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<FormSelect
|
||||
label="Subscription Tier"
|
||||
placeholder="Select Subscription"
|
||||
options={subscriptionTierOptions}
|
||||
value={tenantDetailsForm.watch('subscription_tier') || ''}
|
||||
onValueChange={(value) =>
|
||||
tenantDetailsForm.setValue(
|
||||
'subscription_tier',
|
||||
value === '' ? null : (value as 'basic' | 'professional' | 'enterprise')
|
||||
)
|
||||
}
|
||||
error={tenantDetailsForm.formState.errors.subscription_tier?.message}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Max Users and Max Modules Row */}
|
||||
<div className="flex gap-5">
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
label="Max Users"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
placeholder="Enter number"
|
||||
error={tenantDetailsForm.formState.errors.max_users?.message}
|
||||
{...tenantDetailsForm.register('max_users', {
|
||||
setValueAs: (value) => {
|
||||
if (value === '' || value === null || value === undefined) return null;
|
||||
const num = Number(value);
|
||||
return isNaN(num) ? null : num;
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
label="Max Modules"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
placeholder="Enter number"
|
||||
error={tenantDetailsForm.formState.errors.max_modules?.message}
|
||||
{...tenantDetailsForm.register('max_modules', {
|
||||
setValueAs: (value) => {
|
||||
if (value === '' || value === null || value === undefined) return null;
|
||||
const num = Number(value);
|
||||
return isNaN(num) ? null : num;
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Modules Multiselect */}
|
||||
<MultiselectPaginatedSelect
|
||||
label="Modules"
|
||||
placeholder="Select modules"
|
||||
value={selectedModules}
|
||||
onValueChange={(values) => {
|
||||
setSelectedModules(values);
|
||||
tenantDetailsForm.setValue('modules', values.length > 0 ? values : []);
|
||||
}}
|
||||
onLoadOptions={loadModules}
|
||||
initialOptions={initialModuleOptions}
|
||||
error={tenantDetailsForm.formState.errors.modules?.message}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Contact Details */}
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div className="pb-4 border-b border-[rgba(0,0,0,0.08)]">
|
||||
<h2 className="text-lg font-semibold text-[#0f1724]">Contact Details</h2>
|
||||
<p className="text-sm text-[#6b7280] mt-1">
|
||||
Contact information for the main account administrator.
|
||||
</p>
|
||||
</div>
|
||||
{/* General Error Display */}
|
||||
{contactDetailsForm.formState.errors.root && (
|
||||
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md mb-4">
|
||||
<p className="text-sm text-[#ef4444]">{contactDetailsForm.formState.errors.root.message}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
{/* User Account Information Section */}
|
||||
<div className="border-b border-dashed border-[rgba(0,0,0,0.08)] pb-4">
|
||||
{/* Email */}
|
||||
<FormField
|
||||
label="Email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="Enter email address"
|
||||
error={contactDetailsForm.formState.errors.email?.message}
|
||||
{...contactDetailsForm.register('email')}
|
||||
/>
|
||||
{/* First Name and Last Name Row */}
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
<FormField
|
||||
label="First Name"
|
||||
required
|
||||
placeholder="Enter first name"
|
||||
error={contactDetailsForm.formState.errors.first_name?.message}
|
||||
{...contactDetailsForm.register('first_name')}
|
||||
/>
|
||||
<FormField
|
||||
label="Last Name"
|
||||
required
|
||||
placeholder="Enter last name"
|
||||
error={contactDetailsForm.formState.errors.last_name?.message}
|
||||
{...contactDetailsForm.register('last_name')}
|
||||
/>
|
||||
</div>
|
||||
{/* Password and Confirm Password Row */}
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
<FormField
|
||||
label="Password"
|
||||
type="password"
|
||||
required
|
||||
placeholder="Enter password"
|
||||
error={contactDetailsForm.formState.errors.password?.message}
|
||||
{...contactDetailsForm.register('password')}
|
||||
/>
|
||||
<FormField
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
required
|
||||
placeholder="Confirm password"
|
||||
error={contactDetailsForm.formState.errors.confirmPassword?.message}
|
||||
{...contactDetailsForm.register('confirmPassword')}
|
||||
/>
|
||||
</div>
|
||||
{/* Contact Phone */}
|
||||
<div className="mt-4">
|
||||
<FormField
|
||||
label="Contact Phone"
|
||||
type="tel"
|
||||
placeholder="Enter contact phone"
|
||||
error={contactDetailsForm.formState.errors.contact_phone?.message}
|
||||
{...contactDetailsForm.register('contact_phone')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Organization Address Section */}
|
||||
<div className="border-b border-dashed border-[rgba(0,0,0,0.08)] pb-4">
|
||||
<h3 className="text-sm font-medium text-[#0f1724] mb-4">Organization Address</h3>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
label="Address Line 1"
|
||||
required
|
||||
placeholder="Enter address line 1"
|
||||
error={contactDetailsForm.formState.errors.address_line1?.message}
|
||||
{...contactDetailsForm.register('address_line1')}
|
||||
/>
|
||||
<FormField
|
||||
label="Address Line 2"
|
||||
placeholder="Enter address line 2 (optional)"
|
||||
error={contactDetailsForm.formState.errors.address_line2?.message}
|
||||
{...contactDetailsForm.register('address_line2')}
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
label="City"
|
||||
required
|
||||
placeholder="Enter city"
|
||||
error={contactDetailsForm.formState.errors.city?.message}
|
||||
{...contactDetailsForm.register('city')}
|
||||
/>
|
||||
<FormField
|
||||
label="State"
|
||||
required
|
||||
placeholder="Enter state"
|
||||
error={contactDetailsForm.formState.errors.state?.message}
|
||||
{...contactDetailsForm.register('state')}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
label="Postal Code"
|
||||
required
|
||||
placeholder="Enter postal code"
|
||||
error={contactDetailsForm.formState.errors.postal_code?.message}
|
||||
{...contactDetailsForm.register('postal_code')}
|
||||
/>
|
||||
<FormField
|
||||
label="Country"
|
||||
required
|
||||
placeholder="Enter country"
|
||||
error={contactDetailsForm.formState.errors.country?.message}
|
||||
{...contactDetailsForm.register('country')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Settings */}
|
||||
{currentStep === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div className="pb-4 border-b border-[rgba(0,0,0,0.08)]">
|
||||
<h2 className="text-lg font-semibold text-[#0f1724]">Configuration & Limits</h2>
|
||||
<p className="text-sm text-[#6b7280] mt-1">
|
||||
Set resource limits and security preferences for this tenant.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-md p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-[#0f1724] mb-1">
|
||||
Enable Single Sign-On (SSO)
|
||||
</h4>
|
||||
<p className="text-[10px] text-[#6b7280]">
|
||||
Allow users to log in using their organization's identity provider.
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...settingsForm.register('enable_sso')}
|
||||
className="sr-only peer"
|
||||
checked={settingsForm.watch('enable_sso')}
|
||||
/>
|
||||
<div
|
||||
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'
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-md p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-[#0f1724] mb-1">
|
||||
Enable Two-Factor Authentication (2FA)
|
||||
</h4>
|
||||
<p className="text-[10px] text-[#6b7280]">
|
||||
Enforce 2FA for all users in this tenant organization.
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...settingsForm.register('enable_2fa')}
|
||||
className="sr-only peer"
|
||||
checked={settingsForm.watch('enable_2fa')}
|
||||
/>
|
||||
<div
|
||||
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'
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer Navigation */}
|
||||
<div className="flex justify-end gap-3 mt-6 pt-4">
|
||||
{currentStep > 1 && (
|
||||
<SecondaryButton onClick={handlePrevious} disabled={isSubmitting}>
|
||||
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||
Previous
|
||||
</SecondaryButton>
|
||||
)}
|
||||
{currentStep < 3 ? (
|
||||
<PrimaryButton onClick={handleNext} disabled={isSubmitting}>
|
||||
Next
|
||||
<ChevronRight className="w-4 h-4 ml-2" />
|
||||
</PrimaryButton>
|
||||
) : (
|
||||
<PrimaryButton onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Saving...' : 'Save Tenant'}
|
||||
</PrimaryButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateTenantWizard;
|
||||
626
src/pages/TenantDetails.tsx
Normal file
626
src/pages/TenantDetails.tsx
Normal file
@ -0,0 +1,626 @@
|
||||
import { useState, useEffect, useMemo, type ReactElement } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Calendar,
|
||||
Globe,
|
||||
Hash,
|
||||
Users,
|
||||
Package,
|
||||
FileText,
|
||||
History,
|
||||
CreditCard,
|
||||
Edit,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { Layout } from '@/components/layout/Layout';
|
||||
import {
|
||||
StatusBadge,
|
||||
DataTable,
|
||||
Pagination,
|
||||
UsersTable,
|
||||
RolesTable,
|
||||
type Column,
|
||||
} from '@/components/shared';
|
||||
import { tenantService } from '@/services/tenant-service';
|
||||
import { auditLogService } from '@/services/audit-log-service';
|
||||
import type { Tenant, AssignedModule } from '@/types/tenant';
|
||||
import type { AuditLog } from '@/types/audit-log';
|
||||
import { formatDate } from '@/utils/format-date';
|
||||
|
||||
type TabType = 'overview' | 'users' | 'roles' | 'modules' | 'license' | 'audit-logs' | 'billing';
|
||||
|
||||
const tabs: Array<{ id: TabType; label: string; icon: ReactElement }> = [
|
||||
{ id: 'overview', label: 'Overview', icon: <FileText className="w-4 h-4" /> },
|
||||
{ id: 'users', label: 'Users', icon: <Users className="w-4 h-4" /> },
|
||||
{ id: 'roles', label: 'Roles', icon: <FileText className="w-4 h-4" /> },
|
||||
{ id: 'modules', label: 'Modules', icon: <Package className="w-4 h-4" /> },
|
||||
{ id: 'license', label: 'License', icon: <FileText className="w-4 h-4" /> },
|
||||
{ id: 'audit-logs', label: 'Audit Logs', icon: <History className="w-4 h-4" /> },
|
||||
{ id: 'billing', label: 'Billing', icon: <CreditCard className="w-4 h-4" /> },
|
||||
];
|
||||
|
||||
const getStatusVariant = (status: string): 'success' | 'failure' | 'info' | 'process' => {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'active':
|
||||
return 'success';
|
||||
case 'suspended':
|
||||
return 'process';
|
||||
case 'deleted':
|
||||
return 'failure';
|
||||
default:
|
||||
return 'success';
|
||||
}
|
||||
};
|
||||
|
||||
const getTenantInitials = (name: string): string => {
|
||||
const words = name.trim().split(/\s+/);
|
||||
if (words.length >= 2) {
|
||||
return (words[0][0] + words[1][0]).toUpperCase();
|
||||
}
|
||||
return name.substring(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
const TenantDetails = (): ReactElement => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
||||
const [tenant, setTenant] = useState<Tenant | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
||||
// Modules tab state - using assignedModules from tenant response
|
||||
|
||||
// Audit logs tab state
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
|
||||
const [auditLogsLoading, setAuditLogsLoading] = useState<boolean>(false);
|
||||
const [auditLogsPage, setAuditLogsPage] = useState<number>(1);
|
||||
const [auditLogsLimit] = useState<number>(10);
|
||||
const [auditLogsPagination, setAuditLogsPagination] = useState<{
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasMore: boolean;
|
||||
}>({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
// Fetch tenant details
|
||||
useEffect(() => {
|
||||
const fetchTenant = async (): Promise<void> => {
|
||||
if (!id) return;
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const response = await tenantService.getById(id);
|
||||
if (response.success) {
|
||||
setTenant(response.data);
|
||||
} else {
|
||||
setError('Failed to load tenant details');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error?.message || 'Failed to load tenant details');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTenant();
|
||||
}, [id]);
|
||||
|
||||
// Fetch audit logs for this tenant
|
||||
const fetchAuditLogs = async (): Promise<void> => {
|
||||
if (!id) return;
|
||||
try {
|
||||
setAuditLogsLoading(true);
|
||||
const response = await auditLogService.getAll(auditLogsPage, auditLogsLimit, null, null, id);
|
||||
if (response.success) {
|
||||
setAuditLogs(response.data);
|
||||
setAuditLogsPagination(response.pagination);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load audit logs:', err);
|
||||
} finally {
|
||||
setAuditLogsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch data when tab changes
|
||||
useEffect(() => {
|
||||
if (activeTab === 'audit-logs' && id) {
|
||||
fetchAuditLogs();
|
||||
}
|
||||
}, [activeTab, id, auditLogsPage]);
|
||||
|
||||
// Calculate stats for overview
|
||||
const stats = useMemo(() => {
|
||||
if (!tenant) return null;
|
||||
return {
|
||||
totalUsers: tenant.users?.length || 0,
|
||||
totalModules: tenant.assignedModules?.length || 0,
|
||||
activeModules: tenant.assignedModules?.filter((m) => m.status === 'running')?.length || 0,
|
||||
subscriptionTier: tenant.subscription_tier || 'N/A',
|
||||
};
|
||||
}, [tenant]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Layout
|
||||
currentPage="Tenant Details"
|
||||
breadcrumbs={[
|
||||
{ label: 'QAssure', path: '/dashboard' },
|
||||
{ label: 'Tenant Management', path: '/tenants' },
|
||||
{ label: 'Tenant Details' },
|
||||
]}
|
||||
>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-sm text-[#6b7280]">Loading tenant details...</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !tenant) {
|
||||
return (
|
||||
<Layout
|
||||
currentPage="Tenant Details"
|
||||
breadcrumbs={[
|
||||
{ label: 'QAssure', path: '/dashboard' },
|
||||
{ label: 'Tenant Management', path: '/tenants' },
|
||||
{ label: 'Tenant Details' },
|
||||
]}
|
||||
>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-sm text-red-600">{error || 'Tenant not found'}</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
currentPage="Tenant Details"
|
||||
breadcrumbs={[
|
||||
{ label: 'QAssure', path: '/dashboard' },
|
||||
{ label: 'Tenant Management', path: '/tenants' },
|
||||
{ label: 'Tenant Details' },
|
||||
]}
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Tenant Header Card */}
|
||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg p-4 md:p-6">
|
||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 md:w-16 md:h-16 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0">
|
||||
<span className="text-lg md:text-xl font-normal text-[#9aa6b2]">
|
||||
{getTenantInitials(tenant.name)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h1 className="text-xl md:text-2xl font-bold text-[#0f1724] truncate">
|
||||
{tenant.name}
|
||||
</h1>
|
||||
<StatusBadge variant={getStatusVariant(tenant.status)}>
|
||||
{tenant.status}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4 md:gap-6 text-sm text-[#6b7280]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Hash className="w-4 h-4" />
|
||||
<span className="truncate">{tenant.slug}</span>
|
||||
</div>
|
||||
{tenant.domain && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Globe className="w-4 h-4" />
|
||||
<span className="truncate">{tenant.domain}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>Created {formatDate(tenant.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/tenants')}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-[#112868] bg-white border border-[rgba(0,0,0,0.08)] rounded-md hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
<span>Edit Tenant</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border border-[rgba(0,0,0,0.08)] rounded-lg bg-white">
|
||||
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-6">
|
||||
<div className="flex gap-1 overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
activeTab === tab.id
|
||||
? 'border-[#112868] text-[#112868]'
|
||||
: 'border-transparent text-[#6b7280] hover:text-[#0f1724] hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="p-4 md:p-6">
|
||||
{activeTab === 'overview' && (
|
||||
<OverviewTab tenant={tenant} stats={stats} />
|
||||
)}
|
||||
{activeTab === 'users' && id && (
|
||||
<UsersTable tenantId={id} compact={true} />
|
||||
)}
|
||||
{activeTab === 'roles' && id && (
|
||||
<RolesTable tenantId={id} compact={true} />
|
||||
)}
|
||||
{activeTab === 'modules' && tenant && (
|
||||
<ModulesTab
|
||||
modules={tenant.assignedModules || []}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'license' && <LicenseTab tenant={tenant} />}
|
||||
{activeTab === 'audit-logs' && (
|
||||
<AuditLogsTab
|
||||
auditLogs={auditLogs}
|
||||
isLoading={auditLogsLoading}
|
||||
pagination={auditLogsPagination}
|
||||
currentPage={auditLogsPage}
|
||||
limit={auditLogsLimit}
|
||||
onPageChange={setAuditLogsPage}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'billing' && <BillingTab tenant={tenant} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
// Overview Tab Component
|
||||
interface OverviewTabProps {
|
||||
tenant: Tenant;
|
||||
stats: {
|
||||
totalUsers: number;
|
||||
totalModules: number;
|
||||
activeModules: number;
|
||||
subscriptionTier: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const OverviewTab = ({ tenant, stats }: OverviewTabProps): ReactElement => {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg p-4">
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">Total Users</div>
|
||||
<div className="text-2xl font-bold text-[#0f1724]">{stats?.totalUsers || 0}</div>
|
||||
</div>
|
||||
<div className="bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg p-4">
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">Total Modules</div>
|
||||
<div className="text-2xl font-bold text-[#0f1724]">{stats?.totalModules || 0}</div>
|
||||
</div>
|
||||
<div className="bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg p-4">
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">Active Modules</div>
|
||||
<div className="text-2xl font-bold text-[#0f1724]">{stats?.activeModules || 0}</div>
|
||||
</div>
|
||||
<div className="bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg p-4">
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">Subscription Tier</div>
|
||||
<div className="text-2xl font-bold text-[#0f1724] capitalize">{stats?.subscriptionTier || 'N/A'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* General Information */}
|
||||
<div className="bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg p-4 md:p-6">
|
||||
<h3 className="text-lg font-semibold text-[#0f1724] mb-4">General Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">Tenant Name</div>
|
||||
<div className="text-sm font-normal text-[#0f1724]">{tenant.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">Slug</div>
|
||||
<div className="text-sm font-normal text-[#0f1724]">{tenant.slug}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">Status</div>
|
||||
<StatusBadge variant={getStatusVariant(tenant.status)}>{tenant.status}</StatusBadge>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">Subscription Tier</div>
|
||||
<div className="text-sm font-normal text-[#0f1724] capitalize">
|
||||
{tenant.subscription_tier || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">Max Users</div>
|
||||
<div className="text-sm font-normal text-[#0f1724]">{tenant.max_users || 'Unlimited'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">Max Modules</div>
|
||||
<div className="text-sm font-normal text-[#0f1724]">{tenant.max_modules || 'Unlimited'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">Created At</div>
|
||||
<div className="text-sm font-normal text-[#0f1724]">{formatDate(tenant.created_at)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">Updated At</div>
|
||||
<div className="text-sm font-normal text-[#0f1724]">{formatDate(tenant.updated_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// Modules Tab Component
|
||||
interface ModulesTabProps {
|
||||
modules: AssignedModule[];
|
||||
}
|
||||
|
||||
const ModulesTab = ({ modules }: ModulesTabProps): ReactElement => {
|
||||
const [enabledModules, setEnabledModules] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize enabled modules (assuming all are enabled by default)
|
||||
setEnabledModules(new Set(modules.map((m) => m.id)));
|
||||
}, [modules]);
|
||||
|
||||
const toggleModule = (moduleId: string): void => {
|
||||
setEnabledModules((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(moduleId)) {
|
||||
next.delete(moduleId);
|
||||
} else {
|
||||
next.add(moduleId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
// TODO: Call API to enable/disable module
|
||||
};
|
||||
|
||||
const columns: Column<AssignedModule>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Module Name',
|
||||
render: (module) => (
|
||||
<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">
|
||||
<Package className="w-4 h-4 text-[#9aa6b2]" />
|
||||
</div>
|
||||
<span className="text-sm font-normal text-[#0f1724]">{module.name}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'version',
|
||||
label: 'Version',
|
||||
render: (module) => (
|
||||
<span className="text-sm font-normal text-[#6b7280]">{module.version}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (module) => (
|
||||
<StatusBadge
|
||||
variant={
|
||||
module.status === 'running'
|
||||
? 'success'
|
||||
: module.status === 'degraded'
|
||||
? 'process'
|
||||
: 'info'
|
||||
}
|
||||
>
|
||||
{module.status}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
label: 'Enabled',
|
||||
render: (module) => (
|
||||
<button
|
||||
onClick={() => toggleModule(module.id)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||
enabledModules.has(module.id)
|
||||
? 'bg-green-50 text-green-700 border border-green-200'
|
||||
: 'bg-gray-50 text-gray-700 border border-gray-200'
|
||||
}`}
|
||||
>
|
||||
{enabledModules.has(module.id) ? (
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4" />
|
||||
)}
|
||||
<span>{enabledModules.has(module.id) ? 'Enabled' : 'Disabled'}</span>
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: 'Registered',
|
||||
render: (module) => (
|
||||
<span className="text-sm font-normal text-[#6b7280]">{formatDate(module.created_at)}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-[#0f1724]">Modules</h3>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={modules}
|
||||
keyExtractor={(module) => module.id}
|
||||
isLoading={false}
|
||||
emptyMessage="No modules assigned to this tenant"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// License Tab Component
|
||||
interface LicenseTabProps {
|
||||
tenant: Tenant;
|
||||
}
|
||||
|
||||
const LicenseTab = ({ tenant: _tenant }: LicenseTabProps): ReactElement => {
|
||||
// Placeholder for license data
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="text-lg font-semibold text-[#0f1724]">License Information</h3>
|
||||
<div className="bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg p-4 md:p-6">
|
||||
<div className="text-sm text-[#6b7280]">License information will be displayed here.</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Audit Logs Tab Component
|
||||
interface AuditLogsTabProps {
|
||||
auditLogs: AuditLog[];
|
||||
isLoading: boolean;
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
currentPage: number;
|
||||
limit: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
const AuditLogsTab = ({
|
||||
auditLogs,
|
||||
isLoading,
|
||||
pagination,
|
||||
currentPage,
|
||||
limit,
|
||||
onPageChange,
|
||||
}: AuditLogsTabProps): ReactElement => {
|
||||
const columns: Column<AuditLog>[] = [
|
||||
{
|
||||
key: 'action',
|
||||
label: 'Action',
|
||||
render: (log) => (
|
||||
<span className="text-sm font-medium text-[#0f1724]">{log.action}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'resource_type',
|
||||
label: 'Resource',
|
||||
render: (log) => (
|
||||
<span className="text-sm font-normal text-[#6b7280]">{log.resource_type}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'user',
|
||||
label: 'User',
|
||||
render: (log) => (
|
||||
<span className="text-sm font-normal text-[#6b7280]">
|
||||
{log.user ? `${log.user.first_name} ${log.user.last_name}` : 'System'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'request_method',
|
||||
label: 'Method',
|
||||
render: (log) => (
|
||||
<span className="text-sm font-normal text-[#6b7280]">{log.request_method || 'N/A'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'response_status',
|
||||
label: 'Status',
|
||||
render: (log) => (
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
log.response_status && log.response_status >= 200 && log.response_status < 300
|
||||
? 'text-green-600'
|
||||
: log.response_status && log.response_status >= 400
|
||||
? 'text-red-600'
|
||||
: 'text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{log.response_status || 'N/A'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: 'Date',
|
||||
render: (log) => (
|
||||
<span className="text-sm font-normal text-[#6b7280]">{formatDate(log.created_at)}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-[#0f1724]">Audit Logs</h3>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={auditLogs}
|
||||
keyExtractor={(log) => log.id}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
{pagination.totalPages > 1 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={pagination.totalPages}
|
||||
totalItems={pagination.total}
|
||||
limit={limit}
|
||||
onPageChange={onPageChange}
|
||||
onLimitChange={() => {}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Billing Tab Component
|
||||
interface BillingTabProps {
|
||||
tenant: Tenant;
|
||||
}
|
||||
|
||||
const BillingTab = ({ tenant: _tenant }: BillingTabProps): ReactElement => {
|
||||
// Placeholder for billing data
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="text-lg font-semibold text-[#0f1724]">Billing Information</h3>
|
||||
<div className="bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg p-4 md:p-6">
|
||||
<div className="text-sm text-[#6b7280]">Billing information will be displayed here.</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TenantDetails;
|
||||
@ -5,8 +5,8 @@ import {
|
||||
PrimaryButton,
|
||||
StatusBadge,
|
||||
ActionDropdown,
|
||||
NewTenantModal,
|
||||
ViewTenantModal,
|
||||
// NewTenantModal, // Commented out - using wizard instead
|
||||
// ViewTenantModal, // Commented out - using details page instead
|
||||
EditTenantModal,
|
||||
DeleteConfirmationModal,
|
||||
DataTable,
|
||||
@ -15,6 +15,7 @@ import {
|
||||
type Column,
|
||||
} from '@/components/shared';
|
||||
import { Plus, Download, ArrowUpDown } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { tenantService } from '@/services/tenant-service';
|
||||
import type { Tenant } from '@/types/tenant';
|
||||
import { showToast } from '@/utils/toast';
|
||||
@ -55,11 +56,12 @@ const formatSubscriptionTier = (tier: string | null): string => {
|
||||
};
|
||||
|
||||
const Tenants = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
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);
|
||||
// const [isModalOpen, setIsModalOpen] = useState<boolean>(false); // Commented out - using wizard instead
|
||||
// const [isCreating, setIsCreating] = useState<boolean>(false); // Commented out - using wizard instead
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
@ -83,7 +85,7 @@ const Tenants = (): ReactElement => {
|
||||
const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
||||
|
||||
// View, Edit, Delete modals
|
||||
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
||||
// const [viewModalOpen, setViewModalOpen] = useState<boolean>(false); // Commented out - using details page instead
|
||||
const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
|
||||
const [selectedTenantId, setSelectedTenantId] = useState<string | null>(null);
|
||||
@ -118,35 +120,35 @@ const Tenants = (): ReactElement => {
|
||||
fetchTenants(currentPage, limit, statusFilter, orderBy);
|
||||
}, [currentPage, limit, statusFilter, orderBy]);
|
||||
|
||||
const handleCreateTenant = async (data: {
|
||||
name: string;
|
||||
slug: string;
|
||||
status: 'active' | 'suspended' | 'deleted';
|
||||
settings?: Record<string, unknown> | null;
|
||||
subscription_tier?: string | null;
|
||||
max_users?: number | null;
|
||||
max_modules?: number | null;
|
||||
}): Promise<void> => {
|
||||
try {
|
||||
setIsCreating(true);
|
||||
const response = await tenantService.create(data);
|
||||
const message = response.message || `Tenant created successfully`;
|
||||
const description = response.message ? undefined : `${data.name} has been added`;
|
||||
showToast.success(message, description);
|
||||
// Close modal and refresh tenant list
|
||||
setIsModalOpen(false);
|
||||
await fetchTenants(currentPage, limit, statusFilter, orderBy);
|
||||
} catch (err: any) {
|
||||
throw err; // Let the modal handle the error display
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
// Commented out - using wizard instead
|
||||
// const handleCreateTenant = async (data: {
|
||||
// name: string;
|
||||
// slug: string;
|
||||
// status: 'active' | 'suspended' | 'deleted';
|
||||
// settings?: Record<string, unknown> | null;
|
||||
// subscription_tier?: string | null;
|
||||
// max_users?: number | null;
|
||||
// max_modules?: number | null;
|
||||
// }): Promise<void> => {
|
||||
// try {
|
||||
// setIsCreating(true);
|
||||
// const response = await tenantService.create(data);
|
||||
// const message = response.message || `Tenant created successfully`;
|
||||
// const description = response.message ? undefined : `${data.name} has been added`;
|
||||
// showToast.success(message, description);
|
||||
// // Close modal and refresh tenant list
|
||||
// setIsModalOpen(false);
|
||||
// await fetchTenants(currentPage, limit, statusFilter, orderBy);
|
||||
// } catch (err: any) {
|
||||
// throw err; // Let the modal handle the error display
|
||||
// } finally {
|
||||
// setIsCreating(false);
|
||||
// }
|
||||
// };
|
||||
|
||||
// View tenant handler
|
||||
const handleViewTenant = (tenantId: string): void => {
|
||||
setSelectedTenantId(tenantId);
|
||||
setViewModalOpen(true);
|
||||
navigate(`/tenants/${tenantId}`);
|
||||
};
|
||||
|
||||
// Edit tenant handler
|
||||
@ -417,14 +419,24 @@ const Tenants = (): ReactElement => {
|
||||
<span>Export</span>
|
||||
</button>
|
||||
|
||||
{/* New Tenant Button */}
|
||||
<PrimaryButton
|
||||
{/* New Tenant Button (Old) - Commented out, using wizard instead */}
|
||||
{/* <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 Tenant</span>
|
||||
</PrimaryButton> */}
|
||||
|
||||
{/* Add Tenant Button (New Wizard) */}
|
||||
<PrimaryButton
|
||||
size="default"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => navigate('/tenants/create-wizard')}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span className="text-xs">Add Tenant</span>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
@ -458,16 +470,16 @@ const Tenants = (): ReactElement => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Tenant Modal */}
|
||||
<NewTenantModal
|
||||
{/* New Tenant Modal - Commented out, using wizard instead */}
|
||||
{/* <NewTenantModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSubmit={handleCreateTenant}
|
||||
isLoading={isCreating}
|
||||
/>
|
||||
/> */}
|
||||
|
||||
{/* View Tenant Modal */}
|
||||
<ViewTenantModal
|
||||
{/* View Tenant Modal - Commented out, using details page instead */}
|
||||
{/* <ViewTenantModal
|
||||
isOpen={viewModalOpen}
|
||||
onClose={() => {
|
||||
setViewModalOpen(false);
|
||||
@ -475,7 +487,7 @@ const Tenants = (): ReactElement => {
|
||||
}}
|
||||
tenantId={selectedTenantId}
|
||||
onLoadTenant={loadTenant}
|
||||
/>
|
||||
/> */}
|
||||
|
||||
{/* Edit Tenant Modal */}
|
||||
<EditTenantModal
|
||||
|
||||
@ -6,7 +6,8 @@ export const auditLogService = {
|
||||
page: number = 1,
|
||||
limit: number = 20,
|
||||
method?: string | null,
|
||||
orderBy?: string[] | null
|
||||
orderBy?: string[] | null,
|
||||
tenantId?: string | null
|
||||
): Promise<AuditLogsResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', String(page));
|
||||
@ -14,6 +15,9 @@ export const auditLogService = {
|
||||
if (method) {
|
||||
params.append('method', method);
|
||||
}
|
||||
if (tenantId) {
|
||||
params.append('tenant_id', tenantId);
|
||||
}
|
||||
if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) {
|
||||
params.append('orderBy[]', orderBy[0]);
|
||||
params.append('orderBy[]', orderBy[1]);
|
||||
|
||||
@ -41,7 +41,6 @@ export const moduleService = {
|
||||
params.append('page', String(page));
|
||||
params.append('limit', String(limit));
|
||||
params.append('tenant_id', tenantId);
|
||||
params.append('status', 'running');
|
||||
const response = await apiClient.get<ModulesResponse>(`/modules?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@ -29,6 +29,27 @@ export const roleService = {
|
||||
const response = await apiClient.get<RolesResponse>(`/roles?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
getByTenant: async (
|
||||
tenantId: string,
|
||||
page: number = 1,
|
||||
limit: number = 20,
|
||||
scope?: string | null,
|
||||
orderBy?: string[] | null
|
||||
): Promise<RolesResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', String(page));
|
||||
params.append('limit', String(limit));
|
||||
params.append('tenant_id', tenantId);
|
||||
if (scope) {
|
||||
params.append('scope', scope);
|
||||
}
|
||||
if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) {
|
||||
params.append('orderBy[]', orderBy[0]);
|
||||
params.append('orderBy[]', orderBy[1]);
|
||||
}
|
||||
const response = await apiClient.get<RolesResponse>(`/roles?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
getById: async (id: string): Promise<GetRoleResponse> => {
|
||||
const response = await apiClient.get<GetRoleResponse>(`/roles/${id}`);
|
||||
return response.data;
|
||||
|
||||
@ -38,6 +38,26 @@ export const userService = {
|
||||
const response = await apiClient.get<GetUserResponse>(`/users/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
getByTenant: async (
|
||||
tenantId: string,
|
||||
page: number = 1,
|
||||
limit: number = 20,
|
||||
status?: string | null,
|
||||
orderBy?: string[] | null
|
||||
): Promise<UsersResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', String(page));
|
||||
params.append('limit', String(limit));
|
||||
if (status) {
|
||||
params.append('status', status);
|
||||
}
|
||||
if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) {
|
||||
params.append('orderBy[]', orderBy[0]);
|
||||
params.append('orderBy[]', orderBy[1]);
|
||||
}
|
||||
const response = await apiClient.get<UsersResponse>(`/users/tenant/${tenantId}?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
update: async (id: string, data: UpdateUserRequest): Promise<UpdateUserResponse> => {
|
||||
const response = await apiClient.put<UpdateUserResponse>(`/users/${id}`, data);
|
||||
return response.data;
|
||||
|
||||
@ -35,6 +35,7 @@ export interface CreateRoleRequest {
|
||||
name: string;
|
||||
code: string;
|
||||
description: string;
|
||||
tenant_id?: string | null;
|
||||
module_ids?: string[] | null;
|
||||
permissions?: Permission[] | null;
|
||||
}
|
||||
@ -54,6 +55,7 @@ export interface UpdateRoleRequest {
|
||||
name: string;
|
||||
code: string;
|
||||
description: string;
|
||||
tenant_id?: string | null;
|
||||
module_ids?: string[] | null;
|
||||
permissions?: Permission[] | null;
|
||||
}
|
||||
|
||||
@ -14,6 +14,11 @@ export interface AssignedModule extends Module {
|
||||
TenantModule: TenantModule;
|
||||
}
|
||||
|
||||
export interface TenantUser {
|
||||
email: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface Tenant {
|
||||
id: string;
|
||||
name: string;
|
||||
@ -23,8 +28,12 @@ export interface Tenant {
|
||||
subscription_tier: string | null;
|
||||
max_users: number | null;
|
||||
max_modules: number | null;
|
||||
domain?: string | null;
|
||||
enable_sso?: boolean;
|
||||
enable_2fa?: boolean;
|
||||
modules?: string[]; // Array of module IDs (legacy, for backward compatibility)
|
||||
assignedModules?: AssignedModule[]; // Array of assigned modules with full details
|
||||
users?: TenantUser[]; // Array of tenant users
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
10
src/utils/format-date.ts
Normal file
10
src/utils/format-date.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Formats a date string to a readable format
|
||||
* @param dateString - ISO date string
|
||||
* @returns Formatted date string (e.g., "Jan 23, 2024")
|
||||
*/
|
||||
export const formatDate = (dateString: string | null): string => {
|
||||
if (!dateString) return 'N/A';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user