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:
parent
b0d720c821
commit
d31d8ef8f5
@ -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<boolean>(false);
|
||||
const [dropdownStyle, setDropdownStyle] = useState<{ top?: string; bottom?: string; right: string; width: string }>({ right: '0', width: '0' });
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const dropdownMenuRef = useRef<HTMLDivElement>(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 (
|
||||
<div className={cn('relative', className)} ref={dropdownRef}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
@ -59,8 +100,13 @@ export const ActionDropdown = ({
|
||||
<MoreVertical className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<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">
|
||||
{isOpen && buttonRef.current && createPortal(
|
||||
<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">
|
||||
{onView && (
|
||||
<button
|
||||
@ -93,7 +139,8 @@ export const ActionDropdown = ({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -64,7 +64,7 @@ export const DataTable = <T,>({
|
||||
return (
|
||||
<th
|
||||
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}
|
||||
</th>
|
||||
@ -106,7 +106,7 @@ export const DataTable = <T,>({
|
||||
return (
|
||||
<th
|
||||
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}
|
||||
</th>
|
||||
|
||||
@ -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 */}
|
||||
<FormSelect
|
||||
label="Status"
|
||||
required
|
||||
placeholder="Select Status"
|
||||
options={statusOptions}
|
||||
value={statusValue}
|
||||
onValueChange={(value) => setValue('status', value as 'active' | 'suspended' | 'deleted')}
|
||||
error={errors.status?.message}
|
||||
/>
|
||||
{/* 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>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@ -382,7 +382,7 @@ export const EditUserModal = ({
|
||||
{/* Tenant and Role Row */}
|
||||
<div className="grid grid-cols-2 gap-5 pb-4">
|
||||
<PaginatedSelect
|
||||
label="Tenant"
|
||||
label="Assign Tenant"
|
||||
required
|
||||
placeholder="Select Tenant"
|
||||
value={tenantIdValue || ''}
|
||||
@ -393,7 +393,7 @@ export const EditUserModal = ({
|
||||
/>
|
||||
|
||||
<PaginatedSelect
|
||||
label="Role"
|
||||
label="Assign Role"
|
||||
required
|
||||
placeholder="Select Role"
|
||||
value={roleIdValue || ''}
|
||||
|
||||
478
src/components/shared/NewModuleModal.tsx
Normal file
478
src/components/shared/NewModuleModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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 */}
|
||||
<FormSelect
|
||||
label="Status"
|
||||
required
|
||||
placeholder="Select Status"
|
||||
options={statusOptions}
|
||||
value={statusValue}
|
||||
onValueChange={(value) => setValue('status', value as 'active' | 'suspended' | 'deleted')}
|
||||
error={errors.status?.message}
|
||||
/>
|
||||
{/* 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>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
@ -254,7 +254,7 @@ export const NewUserModal = ({
|
||||
{/* Tenant and Role Row */}
|
||||
<div className="grid grid-cols-2 gap-5 pb-4">
|
||||
<PaginatedSelect
|
||||
label="Tenant"
|
||||
label="Assign Tenant"
|
||||
required
|
||||
placeholder="Select Tenant"
|
||||
value={tenantIdValue}
|
||||
@ -264,7 +264,7 @@ export const NewUserModal = ({
|
||||
/>
|
||||
|
||||
<PaginatedSelect
|
||||
label="Role"
|
||||
label="Assign Role"
|
||||
required
|
||||
placeholder="Select Role"
|
||||
value={roleIdValue}
|
||||
|
||||
@ -22,6 +22,7 @@ export { NewRoleModal } from './NewRoleModal';
|
||||
export { ViewRoleModal } from './ViewRoleModal';
|
||||
export { EditRoleModal } from './EditRoleModal';
|
||||
export { ViewModuleModal } from './ViewModuleModal';
|
||||
export { NewModuleModal } from './NewModuleModal';
|
||||
export { ViewAuditLogModal } from './ViewAuditLogModal';
|
||||
export { PageHeader } from './PageHeader';
|
||||
export type { TabItem } from './PageHeader';
|
||||
@ -4,12 +4,14 @@ import { Layout } from '@/components/layout/Layout';
|
||||
import {
|
||||
StatusBadge,
|
||||
ViewModuleModal,
|
||||
NewModuleModal,
|
||||
PrimaryButton,
|
||||
DataTable,
|
||||
Pagination,
|
||||
FilterDropdown,
|
||||
type Column,
|
||||
} from '@/components/shared';
|
||||
import { Download, ArrowUpDown } from 'lucide-react';
|
||||
import { Plus, Download, ArrowUpDown } from 'lucide-react';
|
||||
import { moduleService } from '@/services/module-service';
|
||||
import type { Module } from '@/types/module';
|
||||
|
||||
@ -40,6 +42,8 @@ const Modules = (): ReactElement => {
|
||||
const [modules, setModules] = useState<Module[]>([]);
|
||||
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);
|
||||
@ -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<Module> => {
|
||||
const response = await moduleService.getById(id);
|
||||
@ -319,6 +337,16 @@ const Modules = (): ReactElement => {
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
<span>Export</span>
|
||||
</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>
|
||||
|
||||
@ -351,6 +379,14 @@ const Modules = (): ReactElement => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Module Modal */}
|
||||
<NewModuleModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSubmit={handleCreateModule}
|
||||
isLoading={isCreating}
|
||||
/>
|
||||
|
||||
{/* View Module Modal */}
|
||||
<ViewModuleModal
|
||||
isOpen={viewModalOpen}
|
||||
|
||||
@ -198,7 +198,7 @@ const Roles = (): ReactElement => {
|
||||
const columns: Column<Role>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Role Name',
|
||||
label: 'Name',
|
||||
render: (role) => (
|
||||
<span className="text-sm font-normal text-[#0f1724]">{role.name}</span>
|
||||
),
|
||||
@ -226,6 +226,15 @@ const Roles = (): ReactElement => {
|
||||
</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',
|
||||
|
||||
@ -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<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 (
|
||||
<Layout
|
||||
currentPage="Tenants"
|
||||
@ -290,211 +429,32 @@ const Tenants = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-sm text-[#6b7280]">Loading tenants...</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Data Table */}
|
||||
<DataTable
|
||||
data={tenants}
|
||||
columns={columns}
|
||||
keyExtractor={(tenant) => tenant.id}
|
||||
mobileCardRenderer={mobileCardRenderer}
|
||||
emptyMessage="No tenants found"
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
/>
|
||||
|
||||
{/* Error State */}
|
||||
{error && !isLoading && (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-sm text-[#ef4444]">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{!isLoading && !error && (
|
||||
<>
|
||||
{/* Desktop Table */}
|
||||
<div className="hidden md:block overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<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
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
{/* 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>
|
||||
|
||||
|
||||
@ -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<GetModuleResponse>(`/modules/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
create: async (data: CreateModuleRequest): Promise<CreateModuleResponse> => {
|
||||
const response = await apiClient.post<CreateModuleResponse>('/modules', data);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ export interface Role {
|
||||
code: string;
|
||||
description?: string;
|
||||
scope: 'platform' | 'tenant' | 'module';
|
||||
is_system?: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user