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:
Yashwin 2026-01-27 18:15:58 +05:30
parent 0264d5caf5
commit f07db4040e
24 changed files with 3174 additions and 489 deletions

View File

@ -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={

View File

@ -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>
<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>
{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>

View File

@ -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">

View File

@ -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,

View File

@ -103,12 +103,12 @@ export const EditTenantModal = ({
if (isOpen && tenantId) {
// Only load if this is a new tenantId or modal was closed and reopened
if (loadedTenantIdRef.current !== tenantId) {
const loadTenant = async (): Promise<void> => {
try {
setIsLoadingTenant(true);
setLoadError(null);
clearErrors();
const tenant = await onLoadTenant(tenantId);
const loadTenant = async (): Promise<void> => {
try {
setIsLoadingTenant(true);
setLoadError(null);
clearErrors();
const tenant = await onLoadTenant(tenantId);
loadedTenantIdRef.current = tenantId;
// Validate subscription_tier to match enum type
@ -133,23 +133,23 @@ export const EditTenantModal = ({
setSelectedModules(tenantModules);
setInitialModuleOptions(initialOptions);
reset({
name: tenant.name,
slug: tenant.slug,
status: tenant.status,
settings: tenant.settings,
reset({
name: tenant.name,
slug: tenant.slug,
status: tenant.status,
settings: tenant.settings,
subscription_tier: validSubscriptionTier,
max_users: tenant.max_users,
max_modules: tenant.max_modules,
max_users: tenant.max_users,
max_modules: tenant.max_modules,
modules: tenantModules,
});
} catch (err: any) {
setLoadError(err?.response?.data?.error?.message || 'Failed to load tenant details');
} finally {
setIsLoadingTenant(false);
}
};
loadTenant();
});
} catch (err: any) {
setLoadError(err?.response?.data?.error?.message || 'Failed to load tenant details');
} finally {
setIsLoadingTenant(false);
}
};
loadTenant();
}
} else if (!isOpen) {
// Only reset when modal is closed
@ -295,14 +295,14 @@ export const EditTenantModal = ({
{/* 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}
<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">

View File

@ -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);
@ -113,21 +115,21 @@ export const EditUserModal = ({
...options,
];
} else {
try {
const tenantResponse = await tenantService.getById(selectedTenantId);
if (tenantResponse.success) {
// Prepend the selected tenant to the options
options = [
{
value: tenantResponse.data.id,
label: tenantResponse.data.name,
},
...options,
];
}
} catch (err) {
// If fetching fails, just continue with existing options
console.warn('Failed to fetch selected tenant:', err);
try {
const tenantResponse = await tenantService.getById(selectedTenantId);
if (tenantResponse.success) {
// Prepend the selected tenant to the options
options = [
{
value: tenantResponse.data.id,
label: tenantResponse.data.name,
},
...options,
];
}
} catch (err) {
// If fetching fails, just continue with existing options
console.warn('Failed to fetch selected tenant:', err);
}
}
}
@ -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,
@ -169,21 +174,21 @@ export const EditUserModal = ({
...options,
];
} else {
try {
const roleResponse = await roleService.getById(selectedRoleId);
if (roleResponse.success) {
// Prepend the selected role to the options
options = [
{
value: roleResponse.data.id,
label: roleResponse.data.name,
},
...options,
];
}
} catch (err) {
// If fetching fails, just continue with existing options
console.warn('Failed to fetch selected role:', err);
try {
const roleResponse = await roleService.getById(selectedRoleId);
if (roleResponse.success) {
// Prepend the selected role to the options
options = [
{
value: roleResponse.data.id,
label: roleResponse.data.name,
},
...options,
];
}
} catch (err) {
// If fetching fails, just continue with existing options
console.warn('Failed to fetch selected role:', err);
}
}
}
@ -200,22 +205,22 @@ export const EditUserModal = ({
if (isOpen && userId) {
// Only load if this is a new userId or modal was closed and reopened
if (loadedUserIdRef.current !== userId) {
const loadUser = async (): Promise<void> => {
try {
setIsLoadingUser(true);
setLoadError(null);
clearErrors();
const user = await onLoadUser(userId);
const loadUser = async (): Promise<void> => {
try {
setIsLoadingUser(true);
setLoadError(null);
clearErrors();
const user = await onLoadUser(userId);
loadedUserIdRef.current = userId;
// Extract tenant and role IDs from nested objects or fallback to direct properties
const tenantId = user.tenant?.id || user.tenant_id || '';
const roleId = user.role?.id || user.role_id || '';
const tenantName = user.tenant?.name || '';
const roleName = user.role?.name || '';
setSelectedTenantId(tenantId);
setSelectedRoleId(roleId);
setSelectedTenantId(tenantId);
setSelectedRoleId(roleId);
setCurrentTenantName(tenantName);
setCurrentRoleName(roleName);
@ -226,22 +231,27 @@ export const EditUserModal = ({
if (roleId && roleName) {
setInitialRoleOption({ value: roleId, label: roleName });
}
reset({
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
status: user.status,
tenant_id: tenantId,
role_id: roleId,
});
} catch (err: any) {
setLoadError(err?.response?.data?.error?.message || 'Failed to load user details');
} finally {
setIsLoadingUser(false);
reset({
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
status: user.status,
tenant_id: defaultTenantId || tenantId,
role_id: roleId,
});
// If defaultTenantId is provided, override tenant_id
if (defaultTenantId) {
setValue('tenant_id', defaultTenantId, { shouldValidate: true });
}
};
loadUser();
} catch (err: any) {
setLoadError(err?.response?.data?.error?.message || 'Failed to load user details');
} finally {
setIsLoadingUser(false);
}
};
loadUser();
}
} else if (!isOpen) {
// Only reset when modal is closed
@ -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,17 +404,19 @@ export const EditUserModal = ({
</div>
{/* Tenant and Role Row */}
<div className="grid grid-cols-2 gap-5 pb-4">
<PaginatedSelect
label="Assign Tenant"
required
placeholder="Select Tenant"
value={tenantIdValue || ''}
onValueChange={(value) => setValue('tenant_id', value)}
onLoadOptions={loadTenants}
initialOption={initialTenantOption || undefined}
error={errors.tenant_id?.message}
/>
<div className={`grid ${defaultTenantId ? 'grid-cols-1' : 'grid-cols-2'} gap-5 pb-4`}>
{!defaultTenantId && (
<PaginatedSelect
label="Assign Tenant"
required
placeholder="Select Tenant"
value={tenantIdValue || ''}
onValueChange={(value) => setValue('tenant_id', value)}
onLoadOptions={loadTenants}
initialOption={initialTenantOption || undefined}
error={errors.tenant_id?.message}
/>
)}
<PaginatedSelect
label="Assign Role"

View File

@ -35,24 +35,24 @@ export const FormField = ({
{required && <span className="text-[#e02424]">*</span>}
</label>
<div className="relative">
<input
id={fieldId}
<input
id={fieldId}
type={isPassword && showPassword ? 'text' : type}
className={cn(
'h-10 w-full px-3.5 py-1 bg-white border rounded-md text-sm transition-colors',
'placeholder:text-[#9aa6b2] text-[#0e1b2a]',
hasError
? 'border-[#ef4444] focus-visible:border-[#ef4444] focus-visible:ring-[#ef4444]/20'
: 'border-[rgba(0,0,0,0.08)] focus-visible:border-[#112868] focus-visible:ring-[#112868]/20',
'focus-visible:outline-none focus-visible:ring-2',
className={cn(
'h-10 w-full px-3.5 py-1 bg-white border rounded-md text-sm transition-colors',
'placeholder:text-[#9aa6b2] text-[#0e1b2a]',
hasError
? 'border-[#ef4444] focus-visible:border-[#ef4444] focus-visible:ring-[#ef4444]/20'
: 'border-[rgba(0,0,0,0.08)] focus-visible:border-[#112868] focus-visible:ring-[#112868]/20',
'focus-visible:outline-none focus-visible:ring-2',
props.disabled && 'bg-[#f3f4f6] cursor-not-allowed opacity-60',
isPassword && 'pr-10',
className
)}
aria-invalid={hasError}
aria-describedby={error ? `${fieldId}-error` : helperText ? `${fieldId}-helper` : undefined}
{...props}
/>
className
)}
aria-invalid={hasError}
aria-describedby={error ? `${fieldId}-error` : helperText ? `${fieldId}-helper` : undefined}
{...props}
/>
{isPassword && (
<button
type="button"

View File

@ -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,
@ -286,11 +290,11 @@ export const NewRoleModal = ({
} else {
// Handle general errors
const errorObj = error?.response?.data?.error;
const errorMessage =
const errorMessage =
(typeof errorObj === 'object' && errorObj !== null && 'message' in errorObj ? errorObj.message : null) ||
(typeof errorObj === 'string' ? errorObj : null) ||
error?.response?.data?.message ||
error?.message ||
error?.response?.data?.message ||
error?.message ||
'Failed to create role. Please try again.';
setError('root', {
type: 'server',

View File

@ -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>
// );
// };

View File

@ -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,16 +271,18 @@ export const NewUserModal = ({
</div>
{/* Tenant and Role Row */}
<div className="grid grid-cols-2 gap-5 pb-4">
<PaginatedSelect
label="Assign Tenant"
required
placeholder="Select Tenant"
value={tenantIdValue}
onValueChange={(value) => setValue('tenant_id', value)}
onLoadOptions={loadTenants}
error={errors.tenant_id?.message}
/>
<div className={`grid ${defaultTenantId ? 'grid-cols-1' : 'grid-cols-2'} gap-5 pb-4`}>
{!defaultTenantId && (
<PaginatedSelect
label="Assign Tenant"
required
placeholder="Select Tenant"
value={tenantIdValue}
onValueChange={(value) => setValue('tenant_id', value)}
onLoadOptions={loadTenants}
error={errors.tenant_id?.message}
/>
)}
<PaginatedSelect
label="Assign Role"

View 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}
/>
</>
);
};

View 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}
/>
</>
);
};

View File

@ -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';
@ -26,4 +26,6 @@ export { ViewModuleModal } from './ViewModuleModal';
export { NewModuleModal } from './NewModuleModal';
export { ViewAuditLogModal } from './ViewAuditLogModal';
export { PageHeader } from './PageHeader';
export type { TabItem } from './PageHeader';
export type { TabItem } from './PageHeader';
export { UsersTable } from './UsersTable';
export { RolesTable } from './RolesTable';

View File

@ -18,45 +18,45 @@ export const StatsGrid = () => {
const { data } = response;
const mappedStats: StatCardData[] = [
{
icon: Building2,
{
icon: Building2,
value: data.totalTenants,
label: 'Total Tenants',
label: 'Total Tenants',
badge: { text: `${data.activeTenants} active`, variant: 'green' },
},
{
icon: CheckCircle2,
},
{
icon: CheckCircle2,
value: data.activeTenants,
label: 'Active Tenants',
label: 'Active Tenants',
badge: {
text: data.totalTenants > 0
? `${Math.round((data.activeTenants / data.totalTenants) * 100)}% Rate`
: '0% Rate',
variant: 'green',
},
},
{
icon: Users,
},
{
icon: Users,
value: data.totalUsers,
label: 'Total Users',
label: 'Total Users',
badge: { text: 'All users', variant: 'gray' },
},
{
icon: TrendingUp,
},
{
icon: TrendingUp,
value: data.activeSessions,
label: 'Active Sessions',
label: 'Active Sessions',
badge: { text: 'Live now', variant: 'gray' },
},
{
icon: Package,
},
{
icon: Package,
value: data.registeredModules,
label: 'Registered Modules',
label: 'Registered Modules',
badge: { text: 'Total', variant: 'gray' },
},
{
icon: Heart,
},
{
icon: Heart,
value: data.healthyModules,
label: 'Healthy Modules',
label: 'Healthy Modules',
badge: {
text: data.registeredModules > 0
? `${Math.round((data.healthyModules / data.registeredModules) * 100)}% Uptime`
@ -65,8 +65,8 @@ export const StatsGrid = () => {
? 'green'
: 'gray',
},
},
];
},
];
setStatsData(mappedStats);
} catch (err) {

View 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
View 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;

View File

@ -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

View File

@ -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]);

View File

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

View File

@ -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;

View File

@ -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;

View File

@ -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;
}

View File

@ -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
View 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' });
};