Enhance ActionDropdown component with dynamic positioning, add subscription tier selection to tenant modals, and implement new module creation functionality in the Modules page. Update DataTable for tenant management and improve role assignment labels in user modals.

This commit is contained in:
Yashwin 2026-01-22 17:08:51 +05:30
parent b0d720c821
commit d31d8ef8f5
14 changed files with 943 additions and 241 deletions

View File

@ -1,4 +1,5 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import { MoreVertical, Eye, Edit, Trash2 } from 'lucide-react'; import { MoreVertical, Eye, Edit, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -17,17 +18,56 @@ export const ActionDropdown = ({
className, className,
}: ActionDropdownProps): ReactElement => { }: ActionDropdownProps): ReactElement => {
const [isOpen, setIsOpen] = useState<boolean>(false); const [isOpen, setIsOpen] = useState<boolean>(false);
const [dropdownStyle, setDropdownStyle] = useState<{ top?: string; bottom?: string; right: string; width: string }>({ right: '0', width: '0' });
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const dropdownMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { 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); setIsOpen(false);
} }
}; };
if (isOpen) { if (isOpen && buttonRef.current) {
document.addEventListener('mousedown', handleClickOutside); 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 () => { return () => {
@ -45,6 +85,7 @@ export const ActionDropdown = ({
return ( return (
<div className={cn('relative', className)} ref={dropdownRef}> <div className={cn('relative', className)} ref={dropdownRef}>
<button <button
ref={buttonRef}
type="button" type="button"
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className={cn( className={cn(
@ -59,8 +100,13 @@ export const ActionDropdown = ({
<MoreVertical className="w-3.5 h-3.5" /> <MoreVertical className="w-3.5 h-3.5" />
</button> </button>
{isOpen && ( {isOpen && buttonRef.current && createPortal(
<div className="absolute right-0 top-8 bg-white border border-[rgba(0,0,0,0.2)] rounded-md shadow-[0px_4px_4px_0px_rgba(0,0,0,0.08)] w-[76px] z-50"> <div
ref={dropdownMenuRef}
data-dropdown-menu="true"
className="fixed bg-white border border-[rgba(0,0,0,0.2)] rounded-md shadow-[0px_4px_4px_0px_rgba(0,0,0,0.08)] z-[250]"
style={dropdownStyle}
>
<div className="flex flex-col py-1.5"> <div className="flex flex-col py-1.5">
{onView && ( {onView && (
<button <button
@ -93,7 +139,8 @@ export const ActionDropdown = ({
</button> </button>
)} )}
</div> </div>
</div> </div>,
document.body
)} )}
</div> </div>
); );

View File

@ -64,7 +64,7 @@ export const DataTable = <T,>({
return ( return (
<th <th
key={column.key} key={column.key}
className={`px-5 py-3 ${alignClass} text-xs font-medium text-[#9aa6b2]`} className={`px-5 py-3 ${alignClass} text-xs font-medium text-[#9aa6b2] uppercase`}
> >
{column.label} {column.label}
</th> </th>
@ -106,7 +106,7 @@ export const DataTable = <T,>({
return ( return (
<th <th
key={column.key} key={column.key}
className={`px-5 py-3 ${alignClass} text-xs font-medium text-[#9aa6b2]`} className={`px-5 py-3 ${alignClass} text-xs font-medium text-[#9aa6b2] uppercase`}
> >
{column.label} {column.label}
</th> </th>

View File

@ -24,7 +24,9 @@ const editTenantSchema = z.object({
message: 'Status is required', message: 'Status is required',
}), }),
settings: z.any().optional().nullable(), 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_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(), 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' }, { value: 'deleted', label: 'Deleted' },
]; ];
const subscriptionTierOptions = [
{ value: 'basic', label: 'Basic' },
{ value: 'professional', label: 'Professional' },
{ value: 'enterprise', label: 'Enterprise' },
];
export const EditTenantModal = ({ export const EditTenantModal = ({
isOpen, isOpen,
onClose, onClose,
@ -71,6 +79,7 @@ export const EditTenantModal = ({
}); });
const statusValue = watch('status'); const statusValue = watch('status');
const subscriptionTierValue = watch('subscription_tier');
// Load tenant data when modal opens // Load tenant data when modal opens
useEffect(() => { useEffect(() => {
@ -81,12 +90,19 @@ export const EditTenantModal = ({
setLoadError(null); setLoadError(null);
clearErrors(); clearErrors();
const tenant = await onLoadTenant(tenantId); 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({ reset({
name: tenant.name, name: tenant.name,
slug: tenant.slug, slug: tenant.slug,
status: tenant.status, status: tenant.status,
settings: tenant.settings, settings: tenant.settings,
subscription_tier: tenant.subscription_tier, subscription_tier: validSubscriptionTier,
max_users: tenant.max_users, max_users: tenant.max_users,
max_modules: tenant.max_modules, max_modules: tenant.max_modules,
}); });
@ -222,16 +238,68 @@ export const EditTenantModal = ({
{...register('slug')} {...register('slug')}
/> />
{/* Status */} {/* Status and Subscription Tier Row */}
<FormSelect <div className="flex gap-5">
label="Status" <div className="flex-1">
required <FormSelect
placeholder="Select Status" label="Status"
options={statusOptions} required
value={statusValue} placeholder="Select Status"
onValueChange={(value) => setValue('status', value as 'active' | 'suspended' | 'deleted')} options={statusOptions}
error={errors.status?.message} 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>
</div> </div>
)} )}
</form> </form>

View File

@ -382,7 +382,7 @@ export const EditUserModal = ({
{/* Tenant and Role Row */} {/* Tenant and Role Row */}
<div className="grid grid-cols-2 gap-5 pb-4"> <div className="grid grid-cols-2 gap-5 pb-4">
<PaginatedSelect <PaginatedSelect
label="Tenant" label="Assign Tenant"
required required
placeholder="Select Tenant" placeholder="Select Tenant"
value={tenantIdValue || ''} value={tenantIdValue || ''}
@ -393,7 +393,7 @@ export const EditUserModal = ({
/> />
<PaginatedSelect <PaginatedSelect
label="Role" label="Assign Role"
required required
placeholder="Select Role" placeholder="Select Role"
value={roleIdValue || ''} value={roleIdValue || ''}

View File

@ -0,0 +1,478 @@
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 } from '@/components/shared';
import { Copy, Check } from 'lucide-react';
import { showToast } from '@/utils/toast';
// Validation schema - matches backend validation
const newModuleSchema = z.object({
module_id: z
.string()
.min(1, 'module_id is required')
.min(3, 'module_id must be at least 3 characters')
.max(100, 'module_id must be at most 100 characters'),
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'),
description: z.string().max(1000, 'description must be at most 1000 characters').optional().nullable(),
version: z
.string()
.min(1, 'version is required')
.max(20, 'version must be at most 20 characters')
.regex(/^[0-9]+\.[0-9]+\.[0-9]+$/, 'version format is invalid (must be X.Y.Z)'),
status: z.enum(['PENDING', 'ACTIVE', 'DEGRADED', 'SUSPENDED', 'DEPRECATED', 'RETIRED'], {
message: 'Invalid status',
}),
runtime_language: z
.string()
.min(1, 'runtime_language is required')
.max(50, 'runtime_language must be at most 50 characters'),
framework: z.string().max(50, 'framework must be at most 50 characters').optional().nullable(),
base_url: z
.string()
.min(1, 'base_url is required')
.max(255, 'base_url must be at most 255 characters')
.url('Invalid URL format'),
health_endpoint: z
.string()
.min(1, 'health_endpoint is required')
.max(255, 'health_endpoint must be at most 255 characters'),
endpoints: z.any().optional().nullable(),
kafka_topics: z.any().optional().nullable(),
cpu_request: z.string().max(20, 'cpu_request must be at most 20 characters').optional().nullable(),
cpu_limit: z.string().max(20, 'cpu_limit must be at most 20 characters').optional().nullable(),
memory_request: z.string().max(20, 'memory_request must be at most 20 characters').optional().nullable(),
memory_limit: z.string().max(20, 'memory_limit must be at most 20 characters').optional().nullable(),
min_replicas: z.number().int().min(1, 'min_replicas must be at least 1').max(50, 'min_replicas must be at most 50').optional().nullable(),
max_replicas: z.number().int().min(1, 'max_replicas must be at least 1').max(50, 'max_replicas must be at most 50').optional().nullable(),
last_health_check: z.string().optional().nullable(),
health_status: z.string().max(20, 'health_status must be at most 20 characters').optional().nullable(),
consecutive_failures: z.number().int().optional().nullable(),
registered_by: z.string().uuid().optional().nullable(),
tenant_id: z.string().uuid().optional().nullable(),
metadata: z.any().optional().nullable(),
});
type NewModuleFormData = z.infer<typeof newModuleSchema>;
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<string | null>(null);
const [copied, setCopied] = useState<boolean>(false);
const {
register,
handleSubmit,
setValue,
watch,
reset,
setError,
clearErrors,
formState: { errors },
} = useForm<NewModuleFormData>({
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<void> => {
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<void> => {
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 (
<Modal
isOpen={isOpen}
onClose={handleClose}
title="Register New Module"
description="Register a new module to integrate with the QAssure platform"
maxWidth="lg"
footer={
<>
<SecondaryButton
type="button"
onClick={handleClose}
disabled={isLoading}
className="px-4 py-2.5 text-sm"
>
{apiKey ? 'Close' : 'Cancel'}
</SecondaryButton>
{!apiKey && (
<PrimaryButton
type="button"
onClick={handleSubmit(handleFormSubmit)}
disabled={isLoading}
size="default"
className="px-4 py-2.5 text-sm"
>
{isLoading ? 'Registering...' : 'Register Module'}
</PrimaryButton>
)}
</>
}
>
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-5 max-h-[70vh] overflow-y-auto">
{/* API Key Display Section */}
{apiKey && (
<div className="mb-6 p-4 bg-[rgba(34,197,94,0.1)] border border-[#22c55e] rounded-md">
<div className="mb-3">
<h3 className="text-sm font-semibold text-[#0f1724] mb-2">
Important: Save Your API Key
</h3>
<p className="text-sm text-[#6b7280] mb-3">
Your API key has been generated. This is the <strong>only time</strong> 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.
</p>
</div>
<div className="flex items-center gap-2 p-3 bg-white border border-[rgba(0,0,0,0.08)] rounded-md">
<code className="flex-1 text-sm font-mono text-[#0f1724] break-all">{apiKey}</code>
<button
type="button"
onClick={handleCopyApiKey}
className="flex items-center gap-1.5 px-3 py-1.5 bg-[#112868] text-white rounded-md text-xs font-medium hover:bg-[#0d1f4e] transition-colors cursor-pointer"
>
{copied ? (
<>
<Check className="w-3.5 h-3.5" />
<span>Copied</span>
</>
) : (
<>
<Copy className="w-3.5 h-3.5" />
<span>Copy</span>
</>
)}
</button>
</div>
</div>
)}
{/* 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">
{/* Basic Information Section */}
<div className="mb-4">
<h3 className="text-sm font-semibold text-[#0f1724] mb-3">Basic Information</h3>
<div className="flex flex-col gap-0">
<FormField
label="Module ID"
required
placeholder="Enter module ID (e.g., my-module)"
error={errors.module_id?.message}
{...register('module_id')}
/>
<FormField
label="Module Name"
required
placeholder="Enter module name"
error={errors.name?.message}
{...register('name')}
/>
<FormField
label="Description"
placeholder="Enter module description (optional)"
error={errors.description?.message}
{...register('description')}
/>
<div className="flex gap-5">
<div className="flex-1">
<FormField
label="Version"
required
placeholder="e.g., 1.0.0"
error={errors.version?.message}
{...register('version')}
/>
</div>
<div className="flex-1">
<FormSelect
label="Status"
placeholder="Select Status"
options={statusOptions}
value={statusValue}
onValueChange={(value) => setValue('status', value as NewModuleFormData['status'])}
error={errors.status?.message}
/>
</div>
</div>
</div>
</div>
{/* Runtime Information Section */}
<div className="mb-4">
<h3 className="text-sm font-semibold text-[#0f1724] mb-3">Runtime Information</h3>
<div className="flex gap-5">
<div className="flex-1">
<FormField
label="Runtime Language"
required
placeholder="e.g., Node.js, Python, Java"
error={errors.runtime_language?.message}
{...register('runtime_language')}
/>
</div>
<div className="flex-1">
<FormField
label="Framework"
placeholder="e.g., Express, Django, Spring (optional)"
error={errors.framework?.message}
{...register('framework')}
/>
</div>
</div>
</div>
{/* URL Configuration Section */}
<div className="mb-4">
<h3 className="text-sm font-semibold text-[#0f1724] mb-3">URL Configuration</h3>
<div className="flex flex-col gap-0">
<FormField
label="Base URL"
required
type="url"
placeholder="https://example.com"
error={errors.base_url?.message}
{...register('base_url')}
/>
<FormField
label="Health Endpoint"
required
placeholder="/health"
error={errors.health_endpoint?.message}
{...register('health_endpoint')}
/>
</div>
</div>
{/* Resource Configuration Section */}
<div className="mb-4">
<h3 className="text-sm font-semibold text-[#0f1724] mb-3">Resource Configuration (Optional)</h3>
<div className="flex flex-col gap-0">
<div className="flex gap-5">
<div className="flex-1">
<FormField
label="CPU Request"
placeholder="e.g., 100m, 0.5"
error={errors.cpu_request?.message}
{...register('cpu_request')}
/>
</div>
<div className="flex-1">
<FormField
label="CPU Limit"
placeholder="e.g., 500m, 1"
error={errors.cpu_limit?.message}
{...register('cpu_limit')}
/>
</div>
</div>
<div className="flex gap-5">
<div className="flex-1">
<FormField
label="Memory Request"
placeholder="e.g., 128Mi, 512Mi"
error={errors.memory_request?.message}
{...register('memory_request')}
/>
</div>
<div className="flex-1">
<FormField
label="Memory Limit"
placeholder="e.g., 256Mi, 1Gi"
error={errors.memory_limit?.message}
{...register('memory_limit')}
/>
</div>
</div>
<div className="flex gap-5">
<div className="flex-1">
<FormField
label="Min Replicas"
type="number"
min="1"
max="50"
step="1"
placeholder="1"
error={errors.min_replicas?.message}
{...register('min_replicas', {
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 Replicas"
type="number"
min="1"
max="50"
step="1"
placeholder="5"
error={errors.max_replicas?.message}
{...register('max_replicas', {
setValueAs: (value) => {
if (value === '' || value === null || value === undefined) return null;
const num = Number(value);
return isNaN(num) ? null : num;
},
})}
/>
</div>
</div>
</div>
</div>
</div>
</form>
</Modal>
);
};

View File

@ -22,7 +22,9 @@ const newTenantSchema = z.object({
message: 'Status is required', message: 'Status is required',
}), }),
settings: z.any().optional().nullable(), 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_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(), 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' }, { value: 'deleted', label: 'Deleted' },
]; ];
const subscriptionTierOptions = [
{ value: 'basic', label: 'Basic' },
{ value: 'professional', label: 'Professional' },
{ value: 'enterprise', label: 'Enterprise' },
];
export const NewTenantModal = ({ export const NewTenantModal = ({
isOpen, isOpen,
onClose, onClose,
@ -69,6 +77,7 @@ export const NewTenantModal = ({
}); });
const statusValue = watch('status'); const statusValue = watch('status');
const subscriptionTierValue = watch('subscription_tier');
// Reset form when modal closes // Reset form when modal closes
useEffect(() => { useEffect(() => {
@ -181,16 +190,68 @@ export const NewTenantModal = ({
{...register('slug')} {...register('slug')}
/> />
{/* Status */} {/* Status and Subscription Tier Row */}
<FormSelect <div className="flex gap-5">
label="Status" <div className="flex-1">
required <FormSelect
placeholder="Select Status" label="Status"
options={statusOptions} required
value={statusValue} placeholder="Select Status"
onValueChange={(value) => setValue('status', value as 'active' | 'suspended' | 'deleted')} options={statusOptions}
error={errors.status?.message} 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>
</div> </div>
</form> </form>
</Modal> </Modal>

View File

@ -254,7 +254,7 @@ export const NewUserModal = ({
{/* Tenant and Role Row */} {/* Tenant and Role Row */}
<div className="grid grid-cols-2 gap-5 pb-4"> <div className="grid grid-cols-2 gap-5 pb-4">
<PaginatedSelect <PaginatedSelect
label="Tenant" label="Assign Tenant"
required required
placeholder="Select Tenant" placeholder="Select Tenant"
value={tenantIdValue} value={tenantIdValue}
@ -264,7 +264,7 @@ export const NewUserModal = ({
/> />
<PaginatedSelect <PaginatedSelect
label="Role" label="Assign Role"
required required
placeholder="Select Role" placeholder="Select Role"
value={roleIdValue} value={roleIdValue}

View File

@ -22,6 +22,7 @@ export { NewRoleModal } from './NewRoleModal';
export { ViewRoleModal } from './ViewRoleModal'; export { ViewRoleModal } from './ViewRoleModal';
export { EditRoleModal } from './EditRoleModal'; export { EditRoleModal } from './EditRoleModal';
export { ViewModuleModal } from './ViewModuleModal'; export { ViewModuleModal } from './ViewModuleModal';
export { NewModuleModal } from './NewModuleModal';
export { ViewAuditLogModal } from './ViewAuditLogModal'; export { ViewAuditLogModal } from './ViewAuditLogModal';
export { PageHeader } from './PageHeader'; export { PageHeader } from './PageHeader';
export type { TabItem } from './PageHeader'; export type { TabItem } from './PageHeader';

View File

@ -4,12 +4,14 @@ import { Layout } from '@/components/layout/Layout';
import { import {
StatusBadge, StatusBadge,
ViewModuleModal, ViewModuleModal,
NewModuleModal,
PrimaryButton,
DataTable, DataTable,
Pagination, Pagination,
FilterDropdown, FilterDropdown,
type Column, type Column,
} from '@/components/shared'; } from '@/components/shared';
import { Download, ArrowUpDown } from 'lucide-react'; import { Plus, Download, ArrowUpDown } from 'lucide-react';
import { moduleService } from '@/services/module-service'; import { moduleService } from '@/services/module-service';
import type { Module } from '@/types/module'; import type { Module } from '@/types/module';
@ -40,6 +42,8 @@ const Modules = (): ReactElement => {
const [modules, setModules] = useState<Module[]>([]); const [modules, setModules] = useState<Module[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [isCreating, setIsCreating] = useState<boolean>(false);
// Pagination state // Pagination state
const [currentPage, setCurrentPage] = useState<number>(1); const [currentPage, setCurrentPage] = useState<number>(1);
@ -100,6 +104,20 @@ const Modules = (): ReactElement => {
setViewModalOpen(true); 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 // Load module for view
const loadModule = async (id: string): Promise<Module> => { const loadModule = async (id: string): Promise<Module> => {
const response = await moduleService.getById(id); const response = await moduleService.getById(id);
@ -319,6 +337,16 @@ const Modules = (): ReactElement => {
<Download className="w-3.5 h-3.5" /> <Download className="w-3.5 h-3.5" />
<span>Export</span> <span>Export</span>
</button> </button>
{/* New Module 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 Module</span>
</PrimaryButton>
</div> </div>
</div> </div>
@ -351,6 +379,14 @@ const Modules = (): ReactElement => {
)} )}
</div> </div>
{/* New Module Modal */}
<NewModuleModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSubmit={handleCreateModule}
isLoading={isCreating}
/>
{/* View Module Modal */} {/* View Module Modal */}
<ViewModuleModal <ViewModuleModal
isOpen={viewModalOpen} isOpen={viewModalOpen}

View File

@ -198,7 +198,7 @@ const Roles = (): ReactElement => {
const columns: Column<Role>[] = [ const columns: Column<Role>[] = [
{ {
key: 'name', key: 'name',
label: 'Role Name', label: 'Name',
render: (role) => ( render: (role) => (
<span className="text-sm font-normal text-[#0f1724]">{role.name}</span> <span className="text-sm font-normal text-[#0f1724]">{role.name}</span>
), ),
@ -226,6 +226,15 @@ const Roles = (): ReactElement => {
</span> </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', key: 'created_at',
label: 'Created Date', label: 'Created Date',

View File

@ -9,8 +9,10 @@ import {
ViewTenantModal, ViewTenantModal,
EditTenantModal, EditTenantModal,
DeleteConfirmationModal, DeleteConfirmationModal,
DataTable,
Pagination, Pagination,
FilterDropdown, FilterDropdown,
type Column,
} from '@/components/shared'; } from '@/components/shared';
import { Plus, Download, ArrowUpDown } from 'lucide-react'; import { Plus, Download, ArrowUpDown } from 'lucide-react';
import { tenantService } from '@/services/tenant-service'; import { tenantService } from '@/services/tenant-service';
@ -214,6 +216,143 @@ const Tenants = (): ReactElement => {
return response.data; return response.data;
}; };
// Define table columns
const columns: Column<Tenant>[] = [
{
key: 'name',
label: 'Tenant Name',
render: (tenant) => (
<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]">
{getTenantInitials(tenant.name)}
</span>
</div>
<span className="text-sm font-normal text-[#0f1724]">
{tenant.name}
</span>
</div>
),
mobileLabel: 'Name',
},
{
key: 'status',
label: 'Status',
render: (tenant) => (
<StatusBadge variant={getStatusVariant(tenant.status)}>
{tenant.status}
</StatusBadge>
),
},
{
key: 'max_users',
label: 'Users',
render: (tenant) => (
<span className="text-sm font-normal text-[#0f1724]">
{tenant.max_users ?? 'N/A'}
</span>
),
},
{
key: 'subscription_tier',
label: 'Plan',
render: (tenant) => (
<span className="text-sm font-normal text-[#0f1724]">
{formatSubscriptionTier(tenant.subscription_tier)}
</span>
),
},
{
key: 'max_modules',
label: 'Modules',
render: (tenant) => (
<span className="text-sm font-normal text-[#0f1724]">
{tenant.max_modules ?? 'N/A'}
</span>
),
},
{
key: 'created_at',
label: 'Joined Date',
render: (tenant) => (
<span className="text-sm font-normal text-[#6b7280]">
{formatDate(tenant.created_at)}
</span>
),
mobileLabel: 'Joined',
},
{
key: 'actions',
label: 'Actions',
align: 'right',
render: (tenant) => (
<div className="flex justify-end">
<ActionDropdown
onView={() => handleViewTenant(tenant.id)}
onEdit={() => handleEditTenant(tenant.id, tenant.name)}
onDelete={() => handleDeleteTenant(tenant.id, tenant.name)}
/>
</div>
),
},
];
// Mobile card renderer
const mobileCardRenderer = (tenant: Tenant) => (
<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]">
{getTenantInitials(tenant.name)}
</span>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-[#0f1724] truncate">
{tenant.name}
</h3>
<p className="text-xs text-[#6b7280] mt-0.5">
{formatDate(tenant.created_at)}
</p>
</div>
</div>
<ActionDropdown
onView={() => handleViewTenant(tenant.id)}
onEdit={() => handleEditTenant(tenant.id, tenant.name)}
onDelete={() => handleDeleteTenant(tenant.id, tenant.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(tenant.status)}>
{tenant.status}
</StatusBadge>
</div>
</div>
<div>
<span className="text-[#9aa6b2]">Plan:</span>
<p className="text-[#0f1724] font-normal mt-1">
{formatSubscriptionTier(tenant.subscription_tier)}
</p>
</div>
<div>
<span className="text-[#9aa6b2]">Users:</span>
<p className="text-[#0f1724] font-normal mt-1">
{tenant.max_users ?? 'N/A'}
</p>
</div>
<div>
<span className="text-[#9aa6b2]">Modules:</span>
<p className="text-[#0f1724] font-normal mt-1">
{tenant.max_modules ?? 'N/A'}
</p>
</div>
</div>
</div>
);
return ( return (
<Layout <Layout
currentPage="Tenants" currentPage="Tenants"
@ -290,211 +429,32 @@ const Tenants = (): ReactElement => {
</div> </div>
</div> </div>
{/* Loading State */} {/* Data Table */}
{isLoading && ( <DataTable
<div className="p-8 text-center"> data={tenants}
<p className="text-sm text-[#6b7280]">Loading tenants...</p> columns={columns}
</div> keyExtractor={(tenant) => tenant.id}
)} mobileCardRenderer={mobileCardRenderer}
emptyMessage="No tenants found"
isLoading={isLoading}
error={error}
/>
{/* Error State */} {/* Table Footer with Pagination */}
{error && !isLoading && ( {pagination.total > 0 && (
<div className="p-8 text-center"> <Pagination
<p className="text-sm text-[#ef4444]">{error}</p> currentPage={currentPage}
</div> totalPages={pagination.totalPages}
)} totalItems={pagination.total}
limit={limit}
{/* Table */} onPageChange={(page: number) => {
{!isLoading && !error && ( setCurrentPage(page);
<> }}
{/* Desktop Table */} onLimitChange={(newLimit: number) => {
<div className="hidden md:block overflow-x-auto"> setLimit(newLimit);
<table className="w-full"> setCurrentPage(1); // Reset to first page when limit changes
<thead> }}
<tr className="bg-[#f5f7fa] border-b border-[rgba(0,0,0,0.08)]"> />
<th className="px-5 py-3 text-left text-xs font-medium text-[#9aa6b2]">
Tenant Name
</th>
<th className="px-5 py-3 text-left text-xs font-medium text-[#9aa6b2]">
Status
</th>
<th className="px-5 py-3 text-left text-xs font-medium text-[#9aa6b2]">
Users
</th>
<th className="px-5 py-3 text-left text-xs font-medium text-[#9aa6b2]">
Plan
</th>
<th className="px-5 py-3 text-left text-xs font-medium text-[#9aa6b2]">
Modules
</th>
<th className="px-5 py-3 text-left text-xs font-medium text-[#9aa6b2]">
Joined Date
</th>
<th className="px-5 py-3 text-right text-xs font-medium text-[#9aa6b2]">
Actions
</th>
</tr>
</thead>
<tbody>
{tenants.length === 0 ? (
<tr>
<td colSpan={7} className="px-5 py-8 text-center text-sm text-[#6b7280]">
No tenants found
</td>
</tr>
) : (
tenants.map((tenant) => (
<tr
key={tenant.id}
className="border-b border-[rgba(0,0,0,0.08)] hover:bg-gray-50 transition-colors"
>
{/* Tenant Name */}
<td className="px-5 py-4">
<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]">
{getTenantInitials(tenant.name)}
</span>
</div>
<span className="text-sm font-normal text-[#0f1724]">
{tenant.name}
</span>
</div>
</td>
{/* Status */}
<td className="px-5 py-4">
<StatusBadge variant={getStatusVariant(tenant.status)}>
{tenant.status}
</StatusBadge>
</td>
{/* Users */}
<td className="px-5 py-4">
<span className="text-sm font-normal text-[#0f1724]">
{tenant.max_users ?? 'N/A'}
</span>
</td>
{/* Plan */}
<td className="px-5 py-4">
<span className="text-sm font-normal text-[#0f1724]">
{formatSubscriptionTier(tenant.subscription_tier)}
</span>
</td>
{/* Modules */}
<td className="px-5 py-4">
<span className="text-sm font-normal text-[#0f1724]">
{tenant.max_modules ?? 'N/A'}
</span>
</td>
{/* Joined Date */}
<td className="px-5 py-4">
<span className="text-sm font-normal text-[#6b7280]">
{formatDate(tenant.created_at)}
</span>
</td>
{/* Actions */}
<td className="px-5 py-4">
<div className="flex justify-end">
<ActionDropdown
onView={() => handleViewTenant(tenant.id)}
onEdit={() => handleEditTenant(tenant.id, tenant.name)}
onDelete={() => handleDeleteTenant(tenant.id, tenant.name)}
/>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Mobile Card View */}
<div className="md:hidden divide-y divide-[rgba(0,0,0,0.08)]">
{tenants.length === 0 ? (
<div className="p-8 text-center">
<p className="text-sm text-[#6b7280]">No tenants found</p>
</div>
) : (
tenants.map((tenant) => (
<div key={tenant.id} 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]">
{getTenantInitials(tenant.name)}
</span>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-[#0f1724] truncate">
{tenant.name}
</h3>
<p className="text-xs text-[#6b7280] mt-0.5">
{formatDate(tenant.created_at)}
</p>
</div>
</div>
<ActionDropdown
onView={() => handleViewTenant(tenant.id)}
onEdit={() => handleEditTenant(tenant.id, tenant.name)}
onDelete={() => handleDeleteTenant(tenant.id, tenant.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(tenant.status)}>
{tenant.status}
</StatusBadge>
</div>
</div>
<div>
<span className="text-[#9aa6b2]">Plan:</span>
<p className="text-[#0f1724] font-normal mt-1">
{formatSubscriptionTier(tenant.subscription_tier)}
</p>
</div>
<div>
<span className="text-[#9aa6b2]">Users:</span>
<p className="text-[#0f1724] font-normal mt-1">
{tenant.max_users ?? 'N/A'}
</p>
</div>
<div>
<span className="text-[#9aa6b2]">Modules:</span>
<p className="text-[#0f1724] font-normal mt-1">
{tenant.max_modules ?? 'N/A'}
</p>
</div>
</div>
</div>
))
)}
</div>
{/* Table Footer with Pagination */}
{pagination.total > 0 && (
<Pagination
currentPage={currentPage}
totalPages={pagination.totalPages}
totalItems={pagination.total}
limit={limit}
onPageChange={(page: number) => {
setCurrentPage(page);
}}
onLimitChange={(newLimit: number) => {
setLimit(newLimit);
setCurrentPage(1); // Reset to first page when limit changes
}}
/>
)}
</>
)} )}
</div> </div>

View File

@ -1,5 +1,5 @@
import apiClient from './api-client'; import apiClient from './api-client';
import type { ModulesResponse, GetModuleResponse } from '@/types/module'; import type { ModulesResponse, GetModuleResponse, CreateModuleRequest, CreateModuleResponse } from '@/types/module';
export const moduleService = { export const moduleService = {
getAll: async ( getAll: async (
@ -25,4 +25,8 @@ export const moduleService = {
const response = await apiClient.get<GetModuleResponse>(`/modules/${id}`); const response = await apiClient.get<GetModuleResponse>(`/modules/${id}`);
return response.data; return response.data;
}, },
create: async (data: CreateModuleRequest): Promise<CreateModuleResponse> => {
const response = await apiClient.post<CreateModuleResponse>('/modules', data);
return response.data;
},
}; };

View File

@ -46,3 +46,40 @@ export interface GetModuleResponse {
success: boolean; success: boolean;
data: Module; 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;
}

View File

@ -4,6 +4,7 @@ export interface Role {
code: string; code: string;
description?: string; description?: string;
scope: 'platform' | 'tenant' | 'module'; scope: 'platform' | 'tenant' | 'module';
is_system?: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }