diff --git a/src/components/shared/ActionDropdown.tsx b/src/components/shared/ActionDropdown.tsx index feff4a5..5867a97 100644 --- a/src/components/shared/ActionDropdown.tsx +++ b/src/components/shared/ActionDropdown.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; import type { ReactElement } from 'react'; import { MoreVertical, Eye, Edit, Trash2 } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -17,17 +18,56 @@ export const ActionDropdown = ({ className, }: ActionDropdownProps): ReactElement => { const [isOpen, setIsOpen] = useState(false); + const [dropdownStyle, setDropdownStyle] = useState<{ top?: string; bottom?: string; right: string; width: string }>({ right: '0', width: '0' }); const dropdownRef = useRef(null); + const buttonRef = useRef(null); + const dropdownMenuRef = useRef(null); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) && + dropdownMenuRef.current && + !dropdownMenuRef.current.contains(event.target as Node) + ) { setIsOpen(false); } }; - if (isOpen) { + if (isOpen && buttonRef.current) { document.addEventListener('mousedown', handleClickOutside); + + // Calculate position when dropdown opens + const rect = buttonRef.current.getBoundingClientRect(); + const spaceBelow = window.innerHeight - rect.bottom; + const spaceAbove = rect.top; + const dropdownHeight = 120; // Approximate height of dropdown menu + + // Determine if should open upward or downward + const shouldOpenUp = spaceBelow < dropdownHeight && spaceAbove > spaceBelow; + + // Calculate dropdown position + const right = window.innerWidth - rect.right; + const width = 76; // Fixed width of dropdown + + if (shouldOpenUp) { + // Position above the button + const bottom = window.innerHeight - rect.top; + setDropdownStyle({ + bottom: `${bottom}px`, + right: `${right}px`, + width: `${width}px`, + }); + } else { + // Position below the button + const top = rect.bottom; + setDropdownStyle({ + top: `${top}px`, + right: `${right}px`, + width: `${width}px`, + }); + } } return () => { @@ -45,6 +85,7 @@ export const ActionDropdown = ({ return (
- {isOpen && ( -
+ {isOpen && buttonRef.current && createPortal( +
{onView && (
-
+
, + document.body )}
); diff --git a/src/components/shared/DataTable.tsx b/src/components/shared/DataTable.tsx index 0bc0f3a..2b26059 100644 --- a/src/components/shared/DataTable.tsx +++ b/src/components/shared/DataTable.tsx @@ -64,7 +64,7 @@ export const DataTable = ({ return ( {column.label} @@ -106,7 +106,7 @@ export const DataTable = ({ return ( {column.label} diff --git a/src/components/shared/EditTenantModal.tsx b/src/components/shared/EditTenantModal.tsx index e146e6c..2ddbde5 100644 --- a/src/components/shared/EditTenantModal.tsx +++ b/src/components/shared/EditTenantModal.tsx @@ -24,7 +24,9 @@ const editTenantSchema = z.object({ message: 'Status is required', }), settings: z.any().optional().nullable(), - subscription_tier: z.string().max(50, 'subscription_tier must be at most 50 characters').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(), }); @@ -46,6 +48,12 @@ const statusOptions = [ { value: 'deleted', label: 'Deleted' }, ]; +const subscriptionTierOptions = [ + { value: 'basic', label: 'Basic' }, + { value: 'professional', label: 'Professional' }, + { value: 'enterprise', label: 'Enterprise' }, +]; + export const EditTenantModal = ({ isOpen, onClose, @@ -71,6 +79,7 @@ export const EditTenantModal = ({ }); const statusValue = watch('status'); + const subscriptionTierValue = watch('subscription_tier'); // Load tenant data when modal opens useEffect(() => { @@ -81,12 +90,19 @@ export const EditTenantModal = ({ setLoadError(null); clearErrors(); const tenant = await onLoadTenant(tenantId); + // Validate subscription_tier to match enum type + const validSubscriptionTier = tenant.subscription_tier === 'basic' || + tenant.subscription_tier === 'professional' || + tenant.subscription_tier === 'enterprise' + ? tenant.subscription_tier + : null; + reset({ name: tenant.name, slug: tenant.slug, status: tenant.status, settings: tenant.settings, - subscription_tier: tenant.subscription_tier, + subscription_tier: validSubscriptionTier, max_users: tenant.max_users, max_modules: tenant.max_modules, }); @@ -222,16 +238,68 @@ export const EditTenantModal = ({ {...register('slug')} /> - {/* Status */} - setValue('status', value as 'active' | 'suspended' | 'deleted')} - error={errors.status?.message} - /> + {/* Status and Subscription Tier Row */} +
+
+ setValue('status', value as 'active' | 'suspended' | 'deleted')} + error={errors.status?.message} + /> +
+
+ setValue('subscription_tier', value === '' ? null : value as 'basic' | 'professional' | 'enterprise')} + error={errors.subscription_tier?.message} + /> +
+
+ + {/* Max Users and Max Modules Row */} +
+
+ { + if (value === '' || value === null || value === undefined) return null; + const num = Number(value); + return isNaN(num) ? null : num; + }, + })} + /> +
+
+ { + if (value === '' || value === null || value === undefined) return null; + const num = Number(value); + return isNaN(num) ? null : num; + }, + })} + /> +
+
)} diff --git a/src/components/shared/EditUserModal.tsx b/src/components/shared/EditUserModal.tsx index 9fefafa..3598784 100644 --- a/src/components/shared/EditUserModal.tsx +++ b/src/components/shared/EditUserModal.tsx @@ -382,7 +382,7 @@ export const EditUserModal = ({ {/* Tenant and Role Row */}
; + +interface NewModuleModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (data: NewModuleFormData) => Promise<{ api_key: { key: string } }>; + isLoading?: boolean; +} + +const statusOptions = [ + { value: 'PENDING', label: 'Pending' }, + { value: 'ACTIVE', label: 'Active' }, + { value: 'DEGRADED', label: 'Degraded' }, + { value: 'SUSPENDED', label: 'Suspended' }, + { value: 'DEPRECATED', label: 'Deprecated' }, + { value: 'RETIRED', label: 'Retired' }, +]; + +export const NewModuleModal = ({ + isOpen, + onClose, + onSubmit, + isLoading = false, +}: NewModuleModalProps): ReactElement | null => { + const [apiKey, setApiKey] = useState(null); + const [copied, setCopied] = useState(false); + + const { + register, + handleSubmit, + setValue, + watch, + reset, + setError, + clearErrors, + formState: { errors }, + } = useForm({ + resolver: zodResolver(newModuleSchema), + defaultValues: { + status: 'PENDING', + description: null, + framework: null, + endpoints: null, + kafka_topics: null, + cpu_request: null, + cpu_limit: null, + memory_request: null, + memory_limit: null, + min_replicas: null, + max_replicas: null, + last_health_check: null, + health_status: null, + consecutive_failures: null, + registered_by: null, + tenant_id: null, + metadata: null, + }, + }); + + const statusValue = watch('status'); + + // Reset form when modal closes + useEffect(() => { + if (!isOpen) { + reset({ + module_id: '', + name: '', + description: null, + version: '', + status: 'PENDING', + runtime_language: '', + framework: null, + base_url: '', + health_endpoint: '', + endpoints: null, + kafka_topics: null, + cpu_request: null, + cpu_limit: null, + memory_request: null, + memory_limit: null, + min_replicas: null, + max_replicas: null, + last_health_check: null, + health_status: null, + consecutive_failures: null, + registered_by: null, + tenant_id: null, + metadata: null, + }); + clearErrors(); + setApiKey(null); + setCopied(false); + } + }, [isOpen, reset, clearErrors]); + + const handleFormSubmit = async (data: NewModuleFormData): Promise => { + clearErrors(); + try { + const response = await onSubmit(data); + // Store API key from response + if (response?.api_key?.key) { + setApiKey(response.api_key.key); + showToast.success( + 'Module registered successfully', + 'Your API key has been generated. Please copy and store it securely - this is the only time you will see it.' + ); + } else { + showToast.success('Module registered successfully'); + onClose(); + } + } 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 === 'module_id' || detail.path === 'description' || detail.path === 'version' || detail.path === 'status' || detail.path === 'runtime_language' || detail.path === 'framework' || detail.path === 'base_url' || detail.path === 'health_endpoint' || detail.path === 'endpoints' || detail.path === 'kafka_topics' || detail.path === 'cpu_request' || detail.path === 'cpu_limit' || detail.path === 'memory_request' || detail.path === 'memory_limit' || detail.path === 'min_replicas' || detail.path === 'max_replicas' || detail.path === 'last_health_check' || detail.path === 'health_status' || detail.path === 'consecutive_failures' || detail.path === 'registered_by' || detail.path === 'tenant_id' || detail.path === 'metadata') { + setError(detail.path as keyof NewModuleFormData, { + 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 module. Please try again.'; + setError('root', { + type: 'server', + message: typeof errorMessage === 'string' ? errorMessage : 'Failed to create module. Please try again.', + }); + } + } + }; + + const handleCopyApiKey = async (): Promise => { + if (apiKey) { + try { + await navigator.clipboard.writeText(apiKey); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + showToast.success('API key copied to clipboard'); + } catch (err) { + showToast.error('Failed to copy API key'); + } + } + }; + + const handleClose = (): void => { + setApiKey(null); + setCopied(false); + onClose(); + }; + + return ( + + + {apiKey ? 'Close' : 'Cancel'} + + {!apiKey && ( + + {isLoading ? 'Registering...' : 'Register Module'} + + )} + + } + > +
+ {/* API Key Display Section */} + {apiKey && ( +
+
+

+ ⚠️ Important: Save Your API Key +

+

+ Your API key has been generated. This is the only time you will see this key. Store it securely in your module project to authenticate with QAssure services. If you lose this key, you cannot retrieve it. +

+
+
+ {apiKey} + +
+
+ )} + + {/* General Error Display */} + {errors.root && ( +
+

{errors.root.message}

+
+ )} + +
+ {/* Basic Information Section */} +
+

Basic Information

+
+ + + + + + +
+
+ +
+
+ setValue('status', value as NewModuleFormData['status'])} + error={errors.status?.message} + /> +
+
+
+
+ + {/* Runtime Information Section */} +
+

Runtime Information

+
+
+ +
+
+ +
+
+
+ + {/* URL Configuration Section */} +
+

URL Configuration

+
+ + + +
+
+ + {/* Resource Configuration Section */} +
+

Resource Configuration (Optional)

+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ { + if (value === '' || value === null || value === undefined) return null; + const num = Number(value); + return isNaN(num) ? null : num; + }, + })} + /> +
+
+ { + if (value === '' || value === null || value === undefined) return null; + const num = Number(value); + return isNaN(num) ? null : num; + }, + })} + /> +
+
+
+
+
+
+
+ ); +}; diff --git a/src/components/shared/NewTenantModal.tsx b/src/components/shared/NewTenantModal.tsx index 9a28340..0bcee4c 100644 --- a/src/components/shared/NewTenantModal.tsx +++ b/src/components/shared/NewTenantModal.tsx @@ -22,7 +22,9 @@ const newTenantSchema = z.object({ message: 'Status is required', }), settings: z.any().optional().nullable(), - subscription_tier: z.string().max(50, 'subscription_tier must be at most 50 characters').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(), }); @@ -42,6 +44,12 @@ const statusOptions = [ { value: 'deleted', label: 'Deleted' }, ]; +const subscriptionTierOptions = [ + { value: 'basic', label: 'Basic' }, + { value: 'professional', label: 'Professional' }, + { value: 'enterprise', label: 'Enterprise' }, +]; + export const NewTenantModal = ({ isOpen, onClose, @@ -69,6 +77,7 @@ export const NewTenantModal = ({ }); const statusValue = watch('status'); + const subscriptionTierValue = watch('subscription_tier'); // Reset form when modal closes useEffect(() => { @@ -181,16 +190,68 @@ export const NewTenantModal = ({ {...register('slug')} /> - {/* Status */} - setValue('status', value as 'active' | 'suspended' | 'deleted')} - error={errors.status?.message} - /> + {/* Status and Subscription Tier Row */} +
+
+ setValue('status', value as 'active' | 'suspended' | 'deleted')} + error={errors.status?.message} + /> +
+
+ setValue('subscription_tier', value === '' ? null : value as 'basic' | 'professional' | 'enterprise')} + error={errors.subscription_tier?.message} + /> +
+
+ + {/* Max Users and Max Modules Row */} +
+
+ { + if (value === '' || value === null || value === undefined) return null; + const num = Number(value); + return isNaN(num) ? null : num; + }, + })} + /> +
+
+ { + if (value === '' || value === null || value === undefined) return null; + const num = Number(value); + return isNaN(num) ? null : num; + }, + })} + /> +
+
diff --git a/src/components/shared/NewUserModal.tsx b/src/components/shared/NewUserModal.tsx index ee1b5b7..82692a8 100644 --- a/src/components/shared/NewUserModal.tsx +++ b/src/components/shared/NewUserModal.tsx @@ -254,7 +254,7 @@ export const NewUserModal = ({ {/* Tenant and Role Row */}
{ const [modules, setModules] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isCreating, setIsCreating] = useState(false); // Pagination state const [currentPage, setCurrentPage] = useState(1); @@ -100,6 +104,20 @@ const Modules = (): ReactElement => { setViewModalOpen(true); }; + // Create module handler + const handleCreateModule = async (data: any): Promise<{ api_key: { key: string } }> => { + try { + setIsCreating(true); + const response = await moduleService.create(data); + await fetchModules(currentPage, limit, statusFilter, orderBy); + return { api_key: response.data.api_key }; + } catch (err: any) { + throw err; + } finally { + setIsCreating(false); + } + }; + // Load module for view const loadModule = async (id: string): Promise => { const response = await moduleService.getById(id); @@ -319,6 +337,16 @@ const Modules = (): ReactElement => { Export + + {/* New Module Button */} + setIsModalOpen(true)} + > + + New Module +
@@ -351,6 +379,14 @@ const Modules = (): ReactElement => { )} + {/* New Module Modal */} + setIsModalOpen(false)} + onSubmit={handleCreateModule} + isLoading={isCreating} + /> + {/* View Module Modal */} { const columns: Column[] = [ { key: 'name', - label: 'Role Name', + label: 'Name', render: (role) => ( {role.name} ), @@ -226,6 +226,15 @@ const Roles = (): ReactElement => { ), }, + { + key: 'is_system', + label: 'System Role', + render: (role) => ( + + {role.is_system ? 'Yes' : 'No'} + + ), + }, { key: 'created_at', label: 'Created Date', diff --git a/src/pages/Tenants.tsx b/src/pages/Tenants.tsx index d739a1d..4a6298f 100644 --- a/src/pages/Tenants.tsx +++ b/src/pages/Tenants.tsx @@ -9,8 +9,10 @@ import { ViewTenantModal, EditTenantModal, DeleteConfirmationModal, + DataTable, Pagination, FilterDropdown, + type Column, } from '@/components/shared'; import { Plus, Download, ArrowUpDown } from 'lucide-react'; import { tenantService } from '@/services/tenant-service'; @@ -214,6 +216,143 @@ const Tenants = (): ReactElement => { return response.data; }; + // Define table columns + const columns: Column[] = [ + { + key: 'name', + label: 'Tenant Name', + render: (tenant) => ( +
+
+ + {getTenantInitials(tenant.name)} + +
+ + {tenant.name} + +
+ ), + mobileLabel: 'Name', + }, + { + key: 'status', + label: 'Status', + render: (tenant) => ( + + {tenant.status} + + ), + }, + { + key: 'max_users', + label: 'Users', + render: (tenant) => ( + + {tenant.max_users ?? 'N/A'} + + ), + }, + { + key: 'subscription_tier', + label: 'Plan', + render: (tenant) => ( + + {formatSubscriptionTier(tenant.subscription_tier)} + + ), + }, + { + key: 'max_modules', + label: 'Modules', + render: (tenant) => ( + + {tenant.max_modules ?? 'N/A'} + + ), + }, + { + key: 'created_at', + label: 'Joined Date', + render: (tenant) => ( + + {formatDate(tenant.created_at)} + + ), + mobileLabel: 'Joined', + }, + { + key: 'actions', + label: 'Actions', + align: 'right', + render: (tenant) => ( +
+ handleViewTenant(tenant.id)} + onEdit={() => handleEditTenant(tenant.id, tenant.name)} + onDelete={() => handleDeleteTenant(tenant.id, tenant.name)} + /> +
+ ), + }, + ]; + + // Mobile card renderer + const mobileCardRenderer = (tenant: Tenant) => ( +
+
+
+
+ + {getTenantInitials(tenant.name)} + +
+
+

+ {tenant.name} +

+

+ {formatDate(tenant.created_at)} +

+
+
+ handleViewTenant(tenant.id)} + onEdit={() => handleEditTenant(tenant.id, tenant.name)} + onDelete={() => handleDeleteTenant(tenant.id, tenant.name)} + /> +
+
+
+ Status: +
+ + {tenant.status} + +
+
+
+ Plan: +

+ {formatSubscriptionTier(tenant.subscription_tier)} +

+
+
+ Users: +

+ {tenant.max_users ?? 'N/A'} +

+
+
+ Modules: +

+ {tenant.max_modules ?? 'N/A'} +

+
+
+
+ ); + return ( { - {/* Loading State */} - {isLoading && ( -
-

Loading tenants...

-
- )} + {/* Data Table */} + tenant.id} + mobileCardRenderer={mobileCardRenderer} + emptyMessage="No tenants found" + isLoading={isLoading} + error={error} + /> - {/* Error State */} - {error && !isLoading && ( -
-

{error}

-
- )} - - {/* Table */} - {!isLoading && !error && ( - <> - {/* Desktop Table */} -
- - - - - - - - - - - - - - {tenants.length === 0 ? ( - - - - ) : ( - tenants.map((tenant) => ( - - {/* Tenant Name */} - - - {/* Status */} - - - {/* Users */} - - - {/* Plan */} - - - {/* Modules */} - - - {/* Joined Date */} - - - {/* Actions */} - - - )) - )} - -
- Tenant Name - - Status - - Users - - Plan - - Modules - - Joined Date - - Actions -
- No tenants found -
-
-
- - {getTenantInitials(tenant.name)} - -
- - {tenant.name} - -
-
- - {tenant.status} - - - - {tenant.max_users ?? 'N/A'} - - - - {formatSubscriptionTier(tenant.subscription_tier)} - - - - {tenant.max_modules ?? 'N/A'} - - - - {formatDate(tenant.created_at)} - - -
- handleViewTenant(tenant.id)} - onEdit={() => handleEditTenant(tenant.id, tenant.name)} - onDelete={() => handleDeleteTenant(tenant.id, tenant.name)} - /> -
-
-
- - {/* Mobile Card View */} -
- {tenants.length === 0 ? ( -
-

No tenants found

-
- ) : ( - tenants.map((tenant) => ( -
-
-
-
- - {getTenantInitials(tenant.name)} - -
-
-

- {tenant.name} -

-

- {formatDate(tenant.created_at)} -

-
-
- handleViewTenant(tenant.id)} - onEdit={() => handleEditTenant(tenant.id, tenant.name)} - onDelete={() => handleDeleteTenant(tenant.id, tenant.name)} - /> -
-
-
- Status: -
- - {tenant.status} - -
-
-
- Plan: -

- {formatSubscriptionTier(tenant.subscription_tier)} -

-
-
- Users: -

- {tenant.max_users ?? 'N/A'} -

-
-
- Modules: -

- {tenant.max_modules ?? 'N/A'} -

-
-
-
- )) - )} -
- - {/* Table Footer with Pagination */} - {pagination.total > 0 && ( - { - setCurrentPage(page); - }} - onLimitChange={(newLimit: number) => { - setLimit(newLimit); - setCurrentPage(1); // Reset to first page when limit changes - }} - /> - )} - + {/* Table Footer with Pagination */} + {pagination.total > 0 && ( + { + setCurrentPage(page); + }} + onLimitChange={(newLimit: number) => { + setLimit(newLimit); + setCurrentPage(1); // Reset to first page when limit changes + }} + /> )} diff --git a/src/services/module-service.ts b/src/services/module-service.ts index 83643d5..85a44cc 100644 --- a/src/services/module-service.ts +++ b/src/services/module-service.ts @@ -1,5 +1,5 @@ import apiClient from './api-client'; -import type { ModulesResponse, GetModuleResponse } from '@/types/module'; +import type { ModulesResponse, GetModuleResponse, CreateModuleRequest, CreateModuleResponse } from '@/types/module'; export const moduleService = { getAll: async ( @@ -25,4 +25,8 @@ export const moduleService = { const response = await apiClient.get(`/modules/${id}`); return response.data; }, + create: async (data: CreateModuleRequest): Promise => { + const response = await apiClient.post('/modules', data); + return response.data; + }, }; diff --git a/src/types/module.ts b/src/types/module.ts index f6cea7d..6623ce1 100644 --- a/src/types/module.ts +++ b/src/types/module.ts @@ -46,3 +46,40 @@ export interface GetModuleResponse { success: boolean; data: Module; } + +export interface CreateModuleRequest { + module_id: string; + name: string; + description?: string | null; + version: string; + status?: 'PENDING' | 'ACTIVE' | 'DEGRADED' | 'SUSPENDED' | 'DEPRECATED' | 'RETIRED'; + runtime_language: string; + framework?: string | null; + base_url: string; + health_endpoint: string; + endpoints?: any | null; + kafka_topics?: any | null; + cpu_request?: string | null; + cpu_limit?: string | null; + memory_request?: string | null; + memory_limit?: string | null; + min_replicas?: number | null; + max_replicas?: number | null; + last_health_check?: string | null; + health_status?: string | null; + consecutive_failures?: number | null; + registered_by?: string | null; + tenant_id?: string | null; + metadata?: any | null; +} + +export interface CreateModuleResponse { + success: boolean; + data: { + module: Module; + api_key: { + key: string; + }; + }; + message?: string; +} diff --git a/src/types/role.ts b/src/types/role.ts index 4f99b44..c394574 100644 --- a/src/types/role.ts +++ b/src/types/role.ts @@ -4,6 +4,7 @@ export interface Role { code: string; description?: string; scope: 'platform' | 'tenant' | 'module'; + is_system?: boolean; created_at: string; updated_at: string; }