From 29c76fcbd60379cae2cbf127a9e8e54cbc88c70a Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Tue, 3 Feb 2026 20:44:29 -0600 Subject: [PATCH] [TASK-2026-02-03-AUDITORIA-UX-UI-ERP-CORE] feat: Add Superadmin Portal (Phase 6) - SuperadminLayout with indigo/purple theme - Tenant management: List, Detail, Create, Edit pages - SuperadminDashboard with global metrics - useTenants hook with CRUD operations - useSuperadminStats hook for dashboard data - Routes for /superadmin/* paths - Features: suspend/activate, stats cards, system health Co-Authored-By: Claude Opus 4.5 --- src/app/layouts/SuperadminLayout.tsx | 386 ++++++++ src/app/layouts/index.ts | 1 + src/app/router/routes.tsx | 96 ++ src/features/superadmin/api/index.ts | 1 + src/features/superadmin/api/tenants.api.ts | 133 +++ src/features/superadmin/hooks/index.ts | 23 + .../superadmin/hooks/useSuperadminStats.ts | 417 +++++++++ src/features/superadmin/hooks/useTenants.ts | 818 +++++++++++++++++ src/features/superadmin/index.ts | 13 + src/features/superadmin/types/index.ts | 6 + src/features/superadmin/types/tenant.ts | 282 ++++++ .../superadmin/SuperadminDashboardPage.tsx | 771 ++++++++++++++++ src/pages/superadmin/index.ts | 14 + .../superadmin/tenants/TenantCreatePage.tsx | 683 ++++++++++++++ .../superadmin/tenants/TenantDetailPage.tsx | 850 ++++++++++++++++++ .../superadmin/tenants/TenantEditPage.tsx | 652 ++++++++++++++ .../superadmin/tenants/TenantsListPage.tsx | 500 +++++++++++ src/pages/superadmin/tenants/index.ts | 9 + 18 files changed, 5655 insertions(+) create mode 100644 src/app/layouts/SuperadminLayout.tsx create mode 100644 src/features/superadmin/api/index.ts create mode 100644 src/features/superadmin/api/tenants.api.ts create mode 100644 src/features/superadmin/hooks/index.ts create mode 100644 src/features/superadmin/hooks/useSuperadminStats.ts create mode 100644 src/features/superadmin/hooks/useTenants.ts create mode 100644 src/features/superadmin/index.ts create mode 100644 src/features/superadmin/types/index.ts create mode 100644 src/features/superadmin/types/tenant.ts create mode 100644 src/pages/superadmin/SuperadminDashboardPage.tsx create mode 100644 src/pages/superadmin/index.ts create mode 100644 src/pages/superadmin/tenants/TenantCreatePage.tsx create mode 100644 src/pages/superadmin/tenants/TenantDetailPage.tsx create mode 100644 src/pages/superadmin/tenants/TenantEditPage.tsx create mode 100644 src/pages/superadmin/tenants/TenantsListPage.tsx create mode 100644 src/pages/superadmin/tenants/index.ts diff --git a/src/app/layouts/SuperadminLayout.tsx b/src/app/layouts/SuperadminLayout.tsx new file mode 100644 index 0000000..c74942f --- /dev/null +++ b/src/app/layouts/SuperadminLayout.tsx @@ -0,0 +1,386 @@ +import { useEffect, type ReactNode } from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import { + LayoutDashboard, + Building2, + CreditCard, + Receipt, + Settings2, + BarChart3, + Menu, + X, + ChevronDown, + LogOut, + Search, + Bell, + Shield, + Users, + ToggleLeft, + Server, +} from 'lucide-react'; +import { cn } from '@utils/cn'; +import { useUIStore } from '@stores/useUIStore'; +import { useAuthStore } from '@stores/useAuthStore'; +import { useIsMobile } from '@hooks/useMediaQuery'; +import { ThemeToggle } from '@components/atoms/ThemeToggle'; +import { CommandPaletteWithRouter, useCommandPalette } from '@components/organisms/CommandPalette'; + +interface SuperadminLayoutProps { + children: ReactNode; +} + +/** + * Navigation item type for superadmin + */ +interface SuperadminNavigationItem { + name: string; + href: string; + icon: React.ComponentType<{ className?: string }>; + badge?: string | number; + children?: SuperadminNavigationItem[]; +} + +/** + * Search button component that opens the command palette + */ +function SearchButton() { + const { open } = useCommandPalette(); + const isMac = typeof navigator !== 'undefined' && navigator.platform.toUpperCase().indexOf('MAC') >= 0; + + return ( + + ); +} + +/** + * Superadmin-specific navigation items + */ +const superadminNavigation: SuperadminNavigationItem[] = [ + { + name: 'Dashboard', + href: '/superadmin/dashboard', + icon: LayoutDashboard, + }, + { + name: 'Tenants', + href: '/superadmin/tenants', + icon: Building2, + children: [ + { name: 'Lista de Tenants', href: '/superadmin/tenants', icon: Building2 }, + { name: 'Usuarios por Tenant', href: '/superadmin/tenants/users', icon: Users }, + ], + }, + { + name: 'Facturación', + href: '/superadmin/billing', + icon: CreditCard, + children: [ + { name: 'Suscripciones', href: '/superadmin/billing/subscriptions', icon: CreditCard }, + { name: 'Facturas', href: '/superadmin/billing/invoices', icon: Receipt }, + ], + }, + { + name: 'Sistema', + href: '/superadmin/system', + icon: Settings2, + children: [ + { name: 'Feature Flags', href: '/superadmin/system/feature-flags', icon: ToggleLeft }, + { name: 'Configuración', href: '/superadmin/system/config', icon: Server }, + ], + }, + { + name: 'Analytics', + href: '/superadmin/analytics', + icon: BarChart3, + }, +]; + +/** + * Internal layout component (used inside CommandPaletteWithRouter) + */ +function SuperadminLayoutInner({ children }: SuperadminLayoutProps) { + const location = useLocation(); + const isMobile = useIsMobile(); + const { sidebarOpen, sidebarCollapsed, toggleSidebar, setSidebarOpen, setIsMobile } = useUIStore(); + const { user, logout } = useAuthStore(); + + useEffect(() => { + setIsMobile(isMobile); + }, [isMobile, setIsMobile]); + + // Close sidebar on mobile when route changes + useEffect(() => { + if (isMobile) { + setSidebarOpen(false); + } + }, [location.pathname, isMobile, setSidebarOpen]); + + /** + * Check if a navigation item is active + */ + const isItemActive = (href: string): boolean => { + return location.pathname === href || location.pathname.startsWith(href + '/'); + }; + + /** + * Check if any child of a navigation item is active + */ + const isParentActive = (item: SuperadminNavigationItem): boolean => { + if (item.children) { + return item.children.some(child => isItemActive(child.href)); + } + return isItemActive(item.href); + }; + + return ( +
+ {/* Mobile sidebar backdrop */} + {isMobile && sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + + {/* Sidebar */} + + + {/* Main content */} +
+ {/* Top bar */} +
+
+ + +
+ +
+ {/* Superadmin badge indicator */} +
+ + Superadmin +
+ + + + + +
+
+ {user?.firstName?.[0]}{user?.lastName?.[0]} +
+ +
+
+
+ + {/* Page content */} +
{children}
+
+
+ ); +} + +/** + * Superadmin Layout with Command Palette support + * + * This layout is designed specifically for the superadmin portal with: + * - Indigo/purple color scheme to distinguish from regular dashboard + * - Superadmin-specific navigation (Tenants, Billing, System, Analytics) + * - Command palette support (Ctrl+K / Cmd+K) + * - Dark mode support + * - Responsive design with mobile hamburger menu + * + * @example + * ```tsx + * function SuperadminDashboard() { + * return ( + * + *

Superadmin Dashboard

+ *
+ * ); + * } + * ``` + */ +export function SuperadminLayout({ children }: SuperadminLayoutProps) { + return ( + + {children} + + ); +} diff --git a/src/app/layouts/index.ts b/src/app/layouts/index.ts index a3acefd..e22494c 100644 --- a/src/app/layouts/index.ts +++ b/src/app/layouts/index.ts @@ -1,2 +1,3 @@ export * from './AuthLayout'; export * from './DashboardLayout'; +export * from './SuperadminLayout'; diff --git a/src/app/router/routes.tsx b/src/app/router/routes.tsx index 04ff51c..d1ab4e3 100644 --- a/src/app/router/routes.tsx +++ b/src/app/router/routes.tsx @@ -2,6 +2,7 @@ import { lazy, Suspense } from 'react'; import { createBrowserRouter, Navigate } from 'react-router-dom'; import { ProtectedRoute } from './ProtectedRoute'; import { DashboardLayout } from '@app/layouts/DashboardLayout'; +import { SuperadminLayout } from '@app/layouts/SuperadminLayout'; import { FullPageSpinner } from '@components/atoms/Spinner'; // Lazy load pages @@ -78,6 +79,13 @@ const StockAdjustmentPage = lazy(() => import('@pages/inventory/StockAdjustmentP const WarehousesListPage = lazy(() => import('@pages/inventory/WarehousesListPage').then(m => ({ default: m.WarehousesListPage }))); const WarehouseDetailPage = lazy(() => import('@pages/inventory/WarehouseDetailPage').then(m => ({ default: m.WarehouseDetailPage }))); +// Superadmin pages +const SuperadminDashboardPage = lazy(() => import('@pages/superadmin/SuperadminDashboardPage').then(m => ({ default: m.SuperadminDashboardPage }))); +const TenantsListPage = lazy(() => import('@pages/superadmin/tenants/TenantsListPage').then(m => ({ default: m.TenantsListPage }))); +const TenantDetailPage = lazy(() => import('@pages/superadmin/tenants/TenantDetailPage').then(m => ({ default: m.TenantDetailPage }))); +const TenantCreatePage = lazy(() => import('@pages/superadmin/tenants/TenantCreatePage').then(m => ({ default: m.TenantCreatePage }))); +const TenantEditPage = lazy(() => import('@pages/superadmin/tenants/TenantEditPage').then(m => ({ default: m.TenantEditPage }))); + function LazyWrapper({ children }: { children: React.ReactNode }) { return }>{children}; } @@ -92,6 +100,16 @@ function DashboardWrapper({ children }: { children: React.ReactNode }) { ); } +function SuperadminWrapper({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} + export const router = createBrowserRouter([ // Public routes { @@ -602,6 +620,84 @@ export const router = createBrowserRouter([ ), }, + // Superadmin routes + { + path: '/superadmin', + element: , + }, + { + path: '/superadmin/dashboard', + element: ( + + + + ), + }, + { + path: '/superadmin/tenants', + element: ( + + + + ), + }, + { + path: '/superadmin/tenants/new', + element: ( + + + + ), + }, + { + path: '/superadmin/tenants/:id', + element: ( + + + + ), + }, + { + path: '/superadmin/tenants/:id/edit', + element: ( + + + + ), + }, + { + path: '/superadmin/billing/*', + element: ( + +
Gestión de Facturación Global - En desarrollo
+
+ ), + }, + { + path: '/superadmin/system/*', + element: ( + +
Configuración del Sistema - En desarrollo
+
+ ), + }, + { + path: '/superadmin/analytics', + element: ( + +
Analytics Global - En desarrollo
+
+ ), + }, + { + path: '/superadmin/*', + element: ( + +
Portal Superadmin - En desarrollo
+
+ ), + }, + // Error pages { path: '/unauthorized', diff --git a/src/features/superadmin/api/index.ts b/src/features/superadmin/api/index.ts new file mode 100644 index 0000000..e33a40e --- /dev/null +++ b/src/features/superadmin/api/index.ts @@ -0,0 +1 @@ +export { tenantsApi, type TenantDashboardStats } from './tenants.api'; diff --git a/src/features/superadmin/api/tenants.api.ts b/src/features/superadmin/api/tenants.api.ts new file mode 100644 index 0000000..0762d80 --- /dev/null +++ b/src/features/superadmin/api/tenants.api.ts @@ -0,0 +1,133 @@ +/** + * Tenants API - Superadmin Portal + * API client for tenant management operations + */ + +import { api } from '@services/api/axios-instance'; +import type { + Tenant, + TenantStats, + TenantFilters, + TenantsResponse, + CreateTenantDto, + UpdateTenantDto, + SuspendTenantDto, + ActivateTenantDto, + TenantWithStats, +} from '../types'; + +const BASE_URL = '/api/v1/superadmin/tenants'; + +export interface TenantDashboardStats { + total: number; + active: number; + trial: number; + suspended: number; + cancelled: number; + pending: number; +} + +export const tenantsApi = { + /** + * Get all tenants with pagination and filters + */ + async getAll(filters?: TenantFilters): Promise { + const params = new URLSearchParams(); + + if (filters?.search) params.append('search', filters.search); + if (filters?.status) params.append('status', filters.status); + if (filters?.planTier) params.append('planTier', filters.planTier); + if (filters?.billingCycle) params.append('billingCycle', filters.billingCycle); + if (filters?.page) params.append('page', filters.page.toString()); + if (filters?.limit) params.append('limit', filters.limit.toString()); + if (filters?.sortBy) params.append('sortBy', filters.sortBy); + if (filters?.sortOrder) params.append('sortOrder', filters.sortOrder); + if (filters?.createdAfter) params.append('createdAfter', filters.createdAfter); + if (filters?.createdBefore) params.append('createdBefore', filters.createdBefore); + if (filters?.hasOverduePayment !== undefined) { + params.append('hasOverduePayment', filters.hasOverduePayment.toString()); + } + + const url = params.toString() ? `${BASE_URL}?${params}` : BASE_URL; + const response = await api.get(url); + return response.data; + }, + + /** + * Get tenant by ID + */ + async getById(id: string): Promise { + const response = await api.get(`${BASE_URL}/${id}`); + return response.data; + }, + + /** + * Get tenant with stats + */ + async getWithStats(id: string): Promise { + const response = await api.get(`${BASE_URL}/${id}/with-stats`); + return response.data; + }, + + /** + * Get tenant statistics + */ + async getStats(id: string): Promise { + const response = await api.get(`${BASE_URL}/${id}/stats`); + return response.data; + }, + + /** + * Get dashboard statistics (counts by status) + */ + async getDashboardStats(): Promise { + const response = await api.get(`${BASE_URL}/dashboard-stats`); + return response.data; + }, + + /** + * Create new tenant + */ + async create(data: CreateTenantDto): Promise { + const response = await api.post(BASE_URL, data); + return response.data; + }, + + /** + * Update tenant + */ + async update(id: string, data: UpdateTenantDto): Promise { + const response = await api.patch(`${BASE_URL}/${id}`, data); + return response.data; + }, + + /** + * Suspend tenant + */ + async suspend(id: string, data: SuspendTenantDto): Promise { + const response = await api.post(`${BASE_URL}/${id}/suspend`, data); + return response.data; + }, + + /** + * Activate tenant + */ + async activate(id: string, data?: ActivateTenantDto): Promise { + const response = await api.post(`${BASE_URL}/${id}/activate`, data || {}); + return response.data; + }, + + /** + * Delete tenant (soft delete) + */ + async delete(id: string): Promise { + await api.delete(`${BASE_URL}/${id}`); + }, + + /** + * Permanently delete tenant (hard delete) + */ + async permanentDelete(id: string): Promise { + await api.delete(`${BASE_URL}/${id}/permanent`); + }, +}; diff --git a/src/features/superadmin/hooks/index.ts b/src/features/superadmin/hooks/index.ts new file mode 100644 index 0000000..119176d --- /dev/null +++ b/src/features/superadmin/hooks/index.ts @@ -0,0 +1,23 @@ +/** + * Superadmin Hooks - Index + * Exports all hooks for the superadmin portal + */ + +export { useSuperadminStats } from './useSuperadminStats'; +export type { + SuperadminOverviewStats, + TenantGrowthDataPoint, + PlanDistribution, + RevenueDataPoint, + RecentTenantActivity, + TopTenant, + SystemHealthMetrics, + UseSuperadminStatsReturn, +} from './useSuperadminStats'; + +export { + useTenants, + useTenant, + useTenantUsageHistory, + useTenantSubscription, +} from './useTenants'; diff --git a/src/features/superadmin/hooks/useSuperadminStats.ts b/src/features/superadmin/hooks/useSuperadminStats.ts new file mode 100644 index 0000000..b738226 --- /dev/null +++ b/src/features/superadmin/hooks/useSuperadminStats.ts @@ -0,0 +1,417 @@ +/** + * useSuperadminStats Hook + * Provides statistics and data for the Superadmin Dashboard + */ + +import { useState, useCallback, useEffect } from 'react'; +import type { + TenantActivity, + TenantPlanTier, +} from '../types'; + +// ==================== Types ==================== + +export interface SuperadminOverviewStats { + totalTenants: number; + activeSubscriptions: number; + monthlyRecurringRevenue: number; + totalUsersAcrossTenants: number; + trialTenants: number; + suspendedTenants: number; + // Change metrics (vs previous period) + tenantsChange: number; + subscriptionsChange: number; + mrrChange: number; + usersChange: number; +} + +export interface TenantGrowthDataPoint { + month: string; + totalTenants: number; + newTenants: number; + churnedTenants: number; +} + +export interface PlanDistribution { + plan: TenantPlanTier; + planLabel: string; + count: number; + percentage: number; + color: string; +} + +export interface RevenueDataPoint { + month: string; + revenue: number; + recurring: number; + oneTime: number; +} + +export interface RecentTenantActivity { + id: string; + type: TenantActivity['type']; + tenantName: string; + description: string; + timestamp: string; + icon: 'signup' | 'upgrade' | 'downgrade' | 'suspension' | 'payment' | 'other'; +} + +export interface TopTenant { + id: string; + name: string; + slug: string; + plan: TenantPlanTier; + usersCount: number; + monthlyRevenue: number; + storageUsedGb: number; + lastActiveAt: string; +} + +export interface SystemHealthMetrics { + apiResponseTimeMs: number; + apiResponseTimeTrend: 'up' | 'down' | 'stable'; + errorRate: number; + errorRateTrend: 'up' | 'down' | 'stable'; + uptimePercent: number; + activeConnections: number; + queuedJobs: number; + lastHealthCheck: string; +} + +export interface UseSuperadminStatsReturn { + // Data + overview: SuperadminOverviewStats | null; + tenantGrowth: TenantGrowthDataPoint[]; + planDistribution: PlanDistribution[]; + revenueTrend: RevenueDataPoint[]; + recentActivity: RecentTenantActivity[]; + topTenants: TopTenant[]; + systemHealth: SystemHealthMetrics | null; + // State + isLoading: boolean; + error: string | null; + // Actions + refresh: () => Promise; + refreshOverview: () => Promise; + refreshActivity: () => Promise; + refreshSystemHealth: () => Promise; +} + +// ==================== Mock Data Generation ==================== + +const PLAN_COLORS: Record = { + free: '#9CA3AF', + starter: '#3B82F6', + professional: '#8B5CF6', + enterprise: '#F59E0B', + custom: '#10B981', +}; + +const PLAN_LABELS: Record = { + free: 'Gratuito', + starter: 'Inicial', + professional: 'Profesional', + enterprise: 'Empresarial', + custom: 'Personalizado', +}; + +function generateMockOverviewStats(): SuperadminOverviewStats { + return { + totalTenants: 247, + activeSubscriptions: 189, + monthlyRecurringRevenue: 485600, + totalUsersAcrossTenants: 3842, + trialTenants: 34, + suspendedTenants: 8, + tenantsChange: 12.5, + subscriptionsChange: 8.3, + mrrChange: 15.7, + usersChange: 22.1, + }; +} + +function generateMockTenantGrowth(): TenantGrowthDataPoint[] { + const months = ['Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic', 'Ene', 'Feb']; + let total = 150; + return months.map((month) => { + const newTenants = Math.floor(Math.random() * 15) + 5; + const churnedTenants = Math.floor(Math.random() * 5); + total = total + newTenants - churnedTenants; + return { + month, + totalTenants: total, + newTenants, + churnedTenants, + }; + }); +} + +function generateMockPlanDistribution(): PlanDistribution[] { + const data: Array<{ plan: TenantPlanTier; count: number }> = [ + { plan: 'free', count: 45 }, + { plan: 'starter', count: 78 }, + { plan: 'professional', count: 89 }, + { plan: 'enterprise', count: 28 }, + { plan: 'custom', count: 7 }, + ]; + const total = data.reduce((acc, item) => acc + item.count, 0); + return data.map((item) => ({ + plan: item.plan, + planLabel: PLAN_LABELS[item.plan], + count: item.count, + percentage: Math.round((item.count / total) * 100), + color: PLAN_COLORS[item.plan], + })); +} + +function generateMockRevenueTrend(): RevenueDataPoint[] { + const months = ['Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic', 'Ene', 'Feb']; + let baseRevenue = 320000; + return months.map((month) => { + const growth = 1 + (Math.random() * 0.08 - 0.02); + baseRevenue = Math.round(baseRevenue * growth); + const recurring = Math.round(baseRevenue * 0.85); + const oneTime = baseRevenue - recurring; + return { + month, + revenue: baseRevenue, + recurring, + oneTime, + }; + }); +} + +function generateMockRecentActivity(): RecentTenantActivity[] { + const activities: RecentTenantActivity[] = [ + { + id: '1', + type: 'created', + tenantName: 'Construcciones del Norte', + description: 'Nuevo registro - Plan Trial', + timestamp: new Date(Date.now() - 1000 * 60 * 15).toISOString(), + icon: 'signup', + }, + { + id: '2', + type: 'plan_changed', + tenantName: 'Metalurgica ABC', + description: 'Upgrade: Starter -> Professional', + timestamp: new Date(Date.now() - 1000 * 60 * 45).toISOString(), + icon: 'upgrade', + }, + { + id: '3', + type: 'payment_received', + tenantName: 'Distribuidora XYZ', + description: 'Pago recibido: $4,500 MXN', + timestamp: new Date(Date.now() - 1000 * 60 * 120).toISOString(), + icon: 'payment', + }, + { + id: '4', + type: 'created', + tenantName: 'Servicios Industriales MX', + description: 'Nuevo registro - Plan Starter', + timestamp: new Date(Date.now() - 1000 * 60 * 180).toISOString(), + icon: 'signup', + }, + { + id: '5', + type: 'suspended', + tenantName: 'Comercializadora Sur', + description: 'Suspendido: Pago vencido (45 dias)', + timestamp: new Date(Date.now() - 1000 * 60 * 240).toISOString(), + icon: 'suspension', + }, + { + id: '6', + type: 'plan_changed', + tenantName: 'Tech Solutions', + description: 'Downgrade: Enterprise -> Professional', + timestamp: new Date(Date.now() - 1000 * 60 * 300).toISOString(), + icon: 'downgrade', + }, + { + id: '7', + type: 'created', + tenantName: 'Alimentos Premium', + description: 'Nuevo registro - Plan Enterprise', + timestamp: new Date(Date.now() - 1000 * 60 * 360).toISOString(), + icon: 'signup', + }, + { + id: '8', + type: 'activated', + tenantName: 'Logistica Express', + description: 'Reactivado despues de suspension', + timestamp: new Date(Date.now() - 1000 * 60 * 420).toISOString(), + icon: 'other', + }, + ]; + return activities; +} + +function generateMockTopTenants(): TopTenant[] { + return [ + { + id: '1', + name: 'Grupo Industrial Monterrey', + slug: 'grupo-industrial-mty', + plan: 'enterprise', + usersCount: 245, + monthlyRevenue: 45000, + storageUsedGb: 128.5, + lastActiveAt: new Date(Date.now() - 1000 * 60 * 5).toISOString(), + }, + { + id: '2', + name: 'Constructora Nacional', + slug: 'constructora-nacional', + plan: 'enterprise', + usersCount: 189, + monthlyRevenue: 38000, + storageUsedGb: 95.2, + lastActiveAt: new Date(Date.now() - 1000 * 60 * 12).toISOString(), + }, + { + id: '3', + name: 'Distribuidora del Bajio', + slug: 'dist-bajio', + plan: 'professional', + usersCount: 124, + monthlyRevenue: 18500, + storageUsedGb: 67.8, + lastActiveAt: new Date(Date.now() - 1000 * 60 * 8).toISOString(), + }, + { + id: '4', + name: 'Metalmecanica Avanzada', + slug: 'metalmecanica-avz', + plan: 'professional', + usersCount: 98, + monthlyRevenue: 15200, + storageUsedGb: 45.3, + lastActiveAt: new Date(Date.now() - 1000 * 60 * 25).toISOString(), + }, + { + id: '5', + name: 'Comercializadora Express', + slug: 'comercializadora-exp', + plan: 'professional', + usersCount: 87, + monthlyRevenue: 12800, + storageUsedGb: 38.9, + lastActiveAt: new Date(Date.now() - 1000 * 60 * 45).toISOString(), + }, + ]; +} + +function generateMockSystemHealth(): SystemHealthMetrics { + return { + apiResponseTimeMs: 127, + apiResponseTimeTrend: 'stable', + errorRate: 0.12, + errorRateTrend: 'down', + uptimePercent: 99.97, + activeConnections: 1247, + queuedJobs: 23, + lastHealthCheck: new Date().toISOString(), + }; +} + +// ==================== Hook ==================== + +export function useSuperadminStats(): UseSuperadminStatsReturn { + const [overview, setOverview] = useState(null); + const [tenantGrowth, setTenantGrowth] = useState([]); + const [planDistribution, setPlanDistribution] = useState([]); + const [revenueTrend, setRevenueTrend] = useState([]); + const [recentActivity, setRecentActivity] = useState([]); + const [topTenants, setTopTenants] = useState([]); + const [systemHealth, setSystemHealth] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchAllData = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + // Simulate API calls with mock data + // In production, replace with actual API calls: + // const [overviewData, growthData, ...] = await Promise.all([ + // superadminApi.getOverview(), + // superadminApi.getTenantGrowth(), + // ... + // ]); + + await new Promise((resolve) => setTimeout(resolve, 800)); + + setOverview(generateMockOverviewStats()); + setTenantGrowth(generateMockTenantGrowth()); + setPlanDistribution(generateMockPlanDistribution()); + setRevenueTrend(generateMockRevenueTrend()); + setRecentActivity(generateMockRecentActivity()); + setTopTenants(generateMockTopTenants()); + setSystemHealth(generateMockSystemHealth()); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar estadisticas'); + } finally { + setIsLoading(false); + } + }, []); + + const refreshOverview = useCallback(async () => { + try { + await new Promise((resolve) => setTimeout(resolve, 300)); + setOverview(generateMockOverviewStats()); + } catch (err) { + console.error('Error refreshing overview:', err); + } + }, []); + + const refreshActivity = useCallback(async () => { + try { + await new Promise((resolve) => setTimeout(resolve, 300)); + setRecentActivity(generateMockRecentActivity()); + } catch (err) { + console.error('Error refreshing activity:', err); + } + }, []); + + const refreshSystemHealth = useCallback(async () => { + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + setSystemHealth(generateMockSystemHealth()); + } catch (err) { + console.error('Error refreshing system health:', err); + } + }, []); + + useEffect(() => { + fetchAllData(); + }, [fetchAllData]); + + // Auto-refresh system health every 30 seconds + useEffect(() => { + const interval = setInterval(() => { + refreshSystemHealth(); + }, 30000); + return () => clearInterval(interval); + }, [refreshSystemHealth]); + + return { + overview, + tenantGrowth, + planDistribution, + revenueTrend, + recentActivity, + topTenants, + systemHealth, + isLoading, + error, + refresh: fetchAllData, + refreshOverview, + refreshActivity, + refreshSystemHealth, + }; +} diff --git a/src/features/superadmin/hooks/useTenants.ts b/src/features/superadmin/hooks/useTenants.ts new file mode 100644 index 0000000..f8d5383 --- /dev/null +++ b/src/features/superadmin/hooks/useTenants.ts @@ -0,0 +1,818 @@ +import { useState, useCallback, useEffect } from 'react'; +import type { + Tenant, + TenantStats, + TenantFilters, + CreateTenantDto, + UpdateTenantDto, + SuspendTenantDto, + ActivateTenantDto, + ChangeTenantPlanDto, + TenantWithStats, + TenantSubscription, + TenantUsageHistory, +} from '../types'; + +// ==================== Mock Data ==================== + +const MOCK_TENANTS: Tenant[] = [ + { + id: 'tenant-001', + name: 'Acme Corporation', + slug: 'acme-corp', + domain: 'acme.erp-example.com', + subdomain: 'acme', + status: 'active', + planTier: 'enterprise', + planId: 'plan-enterprise', + planName: 'Enterprise', + billingCycle: 'annual', + ownerName: 'John Smith', + ownerEmail: 'john.smith@acme.com', + phone: '+1-555-0100', + timezone: 'America/New_York', + locale: 'en-US', + currency: 'USD', + maxUsers: 100, + maxBranches: 25, + maxStorageGb: 500, + maxApiCallsMonthly: 1000000, + enabledModules: ['crm', 'inventory', 'sales', 'purchases', 'hr', 'accounting'], + features: { advancedReporting: true, apiAccess: true, customBranding: true, sso: true }, + createdAt: '2025-01-15T10:00:00Z', + updatedAt: '2026-01-20T14:30:00Z', + lastActiveAt: '2026-02-03T09:15:00Z', + }, + { + id: 'tenant-002', + name: 'TechStart Inc', + slug: 'techstart', + subdomain: 'techstart', + status: 'active', + planTier: 'professional', + planId: 'plan-professional', + planName: 'Professional', + billingCycle: 'monthly', + ownerName: 'Sarah Johnson', + ownerEmail: 'sarah@techstart.io', + phone: '+1-555-0101', + timezone: 'America/Los_Angeles', + locale: 'en-US', + currency: 'USD', + maxUsers: 25, + maxBranches: 5, + maxStorageGb: 100, + maxApiCallsMonthly: 250000, + enabledModules: ['crm', 'inventory', 'sales'], + features: { advancedReporting: true, apiAccess: true, customBranding: false, sso: false }, + createdAt: '2025-06-01T08:00:00Z', + updatedAt: '2026-02-01T11:20:00Z', + lastActiveAt: '2026-02-03T08:45:00Z', + }, + { + id: 'tenant-003', + name: 'Global Retail Co', + slug: 'global-retail', + domain: 'retail.erp-example.com', + subdomain: 'globalretail', + status: 'suspended', + planTier: 'enterprise', + planId: 'plan-enterprise', + planName: 'Enterprise', + billingCycle: 'annual', + ownerName: 'Michael Chen', + ownerEmail: 'mchen@globalretail.com', + phone: '+1-555-0102', + timezone: 'Asia/Shanghai', + locale: 'zh-CN', + currency: 'CNY', + maxUsers: 200, + maxBranches: 50, + maxStorageGb: 1000, + maxApiCallsMonthly: 2000000, + enabledModules: ['crm', 'inventory', 'sales', 'purchases', 'hr', 'accounting', 'pos'], + features: { advancedReporting: true, apiAccess: true, customBranding: true, sso: true }, + createdAt: '2024-11-01T06:00:00Z', + updatedAt: '2026-01-28T09:00:00Z', + suspendedAt: '2026-01-28T09:00:00Z', + suspensionReason: 'Payment overdue - 45 days', + lastActiveAt: '2026-01-15T12:00:00Z', + }, + { + id: 'tenant-004', + name: 'StartupLabs', + slug: 'startuplabs', + subdomain: 'startuplabs', + status: 'trial', + planTier: 'starter', + planId: 'plan-starter', + planName: 'Starter', + billingCycle: 'monthly', + ownerName: 'Emily Davis', + ownerEmail: 'emily@startuplabs.co', + timezone: 'Europe/London', + locale: 'en-GB', + currency: 'GBP', + maxUsers: 5, + maxBranches: 1, + maxStorageGb: 10, + maxApiCallsMonthly: 50000, + enabledModules: ['crm', 'sales'], + features: { advancedReporting: false, apiAccess: false, customBranding: false, sso: false }, + createdAt: '2026-01-25T14:00:00Z', + updatedAt: '2026-01-25T14:00:00Z', + trialEndsAt: '2026-02-24T14:00:00Z', + lastActiveAt: '2026-02-02T16:30:00Z', + }, + { + id: 'tenant-005', + name: 'MedCare Solutions', + slug: 'medcare', + domain: 'erp.medcare.health', + subdomain: 'medcare', + status: 'active', + planTier: 'professional', + planId: 'plan-professional', + planName: 'Professional', + billingCycle: 'annual', + ownerName: 'Dr. Robert Wilson', + ownerEmail: 'rwilson@medcare.health', + phone: '+1-555-0105', + timezone: 'America/Chicago', + locale: 'en-US', + currency: 'USD', + maxUsers: 50, + maxBranches: 10, + maxStorageGb: 250, + maxApiCallsMonthly: 500000, + enabledModules: ['crm', 'inventory', 'sales', 'hr'], + features: { advancedReporting: true, apiAccess: true, customBranding: true, sso: false }, + createdAt: '2025-03-10T12:00:00Z', + updatedAt: '2026-01-15T10:00:00Z', + lastActiveAt: '2026-02-03T07:00:00Z', + }, + { + id: 'tenant-006', + name: 'BuildRight Construction', + slug: 'buildright', + subdomain: 'buildright', + status: 'pending', + planTier: 'professional', + planId: 'plan-professional', + planName: 'Professional', + billingCycle: 'monthly', + ownerName: 'James Martinez', + ownerEmail: 'jmartinez@buildright.com', + phone: '+1-555-0106', + timezone: 'America/Denver', + locale: 'en-US', + currency: 'USD', + maxUsers: 30, + maxBranches: 8, + maxStorageGb: 150, + maxApiCallsMonthly: 300000, + enabledModules: ['crm', 'inventory', 'sales', 'purchases', 'projects'], + features: { advancedReporting: true, apiAccess: true, customBranding: false, sso: false }, + createdAt: '2026-02-01T09:00:00Z', + updatedAt: '2026-02-01T09:00:00Z', + }, +]; + +const MOCK_TENANT_STATS: Record = { + 'tenant-001': { + tenantId: 'tenant-001', + totalUsers: 78, + activeUsers: 65, + inactiveUsers: 13, + pendingInvitations: 5, + storageUsedGb: 245.5, + storageUsedPercent: 49.1, + apiCallsThisMonth: 456789, + apiCallsPercent: 45.7, + apiCallsLastMonth: 512000, + sessionsToday: 45, + sessionsThisWeek: 312, + sessionsThisMonth: 1250, + avgSessionDurationMinutes: 35, + totalBranches: 18, + totalDocuments: 125000, + totalTransactions: 45000, + periodStart: '2026-02-01T00:00:00Z', + periodEnd: '2026-02-28T23:59:59Z', + updatedAt: '2026-02-03T10:00:00Z', + }, + 'tenant-002': { + tenantId: 'tenant-002', + totalUsers: 18, + activeUsers: 15, + inactiveUsers: 3, + pendingInvitations: 2, + storageUsedGb: 45.2, + storageUsedPercent: 45.2, + apiCallsThisMonth: 89000, + apiCallsPercent: 35.6, + apiCallsLastMonth: 75000, + sessionsToday: 12, + sessionsThisWeek: 85, + sessionsThisMonth: 340, + avgSessionDurationMinutes: 28, + totalBranches: 3, + totalDocuments: 15000, + totalTransactions: 8500, + periodStart: '2026-02-01T00:00:00Z', + periodEnd: '2026-02-28T23:59:59Z', + updatedAt: '2026-02-03T10:00:00Z', + }, + 'tenant-003': { + tenantId: 'tenant-003', + totalUsers: 156, + activeUsers: 0, + inactiveUsers: 156, + pendingInvitations: 0, + storageUsedGb: 680.8, + storageUsedPercent: 68.1, + apiCallsThisMonth: 0, + apiCallsPercent: 0, + apiCallsLastMonth: 1800000, + sessionsToday: 0, + sessionsThisWeek: 0, + sessionsThisMonth: 0, + avgSessionDurationMinutes: 0, + totalBranches: 42, + totalDocuments: 450000, + totalTransactions: 250000, + periodStart: '2026-02-01T00:00:00Z', + periodEnd: '2026-02-28T23:59:59Z', + updatedAt: '2026-02-03T10:00:00Z', + }, + 'tenant-004': { + tenantId: 'tenant-004', + totalUsers: 3, + activeUsers: 3, + inactiveUsers: 0, + pendingInvitations: 1, + storageUsedGb: 0.8, + storageUsedPercent: 8, + apiCallsThisMonth: 2500, + apiCallsPercent: 5, + apiCallsLastMonth: 0, + sessionsToday: 5, + sessionsThisWeek: 28, + sessionsThisMonth: 45, + avgSessionDurationMinutes: 42, + totalBranches: 1, + totalDocuments: 150, + totalTransactions: 25, + periodStart: '2026-02-01T00:00:00Z', + periodEnd: '2026-02-28T23:59:59Z', + updatedAt: '2026-02-03T10:00:00Z', + }, + 'tenant-005': { + tenantId: 'tenant-005', + totalUsers: 38, + activeUsers: 32, + inactiveUsers: 6, + pendingInvitations: 3, + storageUsedGb: 125.3, + storageUsedPercent: 50.1, + apiCallsThisMonth: 210000, + apiCallsPercent: 42, + apiCallsLastMonth: 195000, + sessionsToday: 28, + sessionsThisWeek: 195, + sessionsThisMonth: 780, + avgSessionDurationMinutes: 32, + totalBranches: 7, + totalDocuments: 65000, + totalTransactions: 28000, + periodStart: '2026-02-01T00:00:00Z', + periodEnd: '2026-02-28T23:59:59Z', + updatedAt: '2026-02-03T10:00:00Z', + }, + 'tenant-006': { + tenantId: 'tenant-006', + totalUsers: 0, + activeUsers: 0, + inactiveUsers: 0, + pendingInvitations: 0, + storageUsedGb: 0, + storageUsedPercent: 0, + apiCallsThisMonth: 0, + apiCallsPercent: 0, + apiCallsLastMonth: 0, + sessionsToday: 0, + sessionsThisWeek: 0, + sessionsThisMonth: 0, + avgSessionDurationMinutes: 0, + totalBranches: 0, + totalDocuments: 0, + totalTransactions: 0, + periodStart: '2026-02-01T00:00:00Z', + periodEnd: '2026-02-28T23:59:59Z', + updatedAt: '2026-02-03T10:00:00Z', + }, +}; + +// ==================== Simulated API Delay ==================== + +const simulateApiDelay = (ms = 500) => new Promise((resolve) => setTimeout(resolve, ms)); + +// ==================== Hook State ==================== + +interface UseTenantsState { + tenants: Tenant[]; + total: number; + page: number; + totalPages: number; + isLoading: boolean; + error: string | null; +} + +interface UseTenantsReturn extends UseTenantsState { + filters: TenantFilters; + setFilters: (filters: TenantFilters) => void; + refresh: () => Promise; + createTenant: (data: CreateTenantDto) => Promise; + updateTenant: (id: string, data: UpdateTenantDto) => Promise; + deleteTenant: (id: string) => Promise; + suspendTenant: (id: string, data: SuspendTenantDto) => Promise; + activateTenant: (id: string, data?: ActivateTenantDto) => Promise; + changePlan: (id: string, data: ChangeTenantPlanDto) => Promise; + getTenantStats: (id: string) => Promise; +} + +// ==================== useTenants Hook ==================== + +export function useTenants(initialFilters?: TenantFilters): UseTenantsReturn { + const [state, setState] = useState({ + tenants: [], + total: 0, + page: 1, + totalPages: 1, + isLoading: true, + error: null, + }); + + const [filters, setFilters] = useState( + initialFilters || { page: 1, limit: 10, sortBy: 'createdAt', sortOrder: 'desc' } + ); + + const fetchTenants = useCallback(async () => { + setState((prev) => ({ ...prev, isLoading: true, error: null })); + + try { + await simulateApiDelay(); + + // Filter mock data + let filteredTenants = [...MOCK_TENANTS]; + + // Search filter + if (filters.search) { + const searchLower = filters.search.toLowerCase(); + filteredTenants = filteredTenants.filter( + (t) => + t.name.toLowerCase().includes(searchLower) || + t.slug.toLowerCase().includes(searchLower) || + t.ownerEmail.toLowerCase().includes(searchLower) || + t.ownerName.toLowerCase().includes(searchLower) + ); + } + + // Status filter + if (filters.status) { + filteredTenants = filteredTenants.filter((t) => t.status === filters.status); + } + + // Plan tier filter + if (filters.planTier) { + filteredTenants = filteredTenants.filter((t) => t.planTier === filters.planTier); + } + + // Billing cycle filter + if (filters.billingCycle) { + filteredTenants = filteredTenants.filter((t) => t.billingCycle === filters.billingCycle); + } + + // Sort + const sortBy = filters.sortBy || 'createdAt'; + const sortOrder = filters.sortOrder || 'desc'; + filteredTenants.sort((a, b) => { + const aVal = a[sortBy as keyof Tenant] ?? ''; + const bVal = b[sortBy as keyof Tenant] ?? ''; + const comparison = String(aVal).localeCompare(String(bVal)); + return sortOrder === 'desc' ? -comparison : comparison; + }); + + // Pagination + const limit = filters.limit || 10; + const page = filters.page || 1; + const total = filteredTenants.length; + const totalPages = Math.ceil(total / limit); + const startIndex = (page - 1) * limit; + const paginatedTenants = filteredTenants.slice(startIndex, startIndex + limit); + + setState({ + tenants: paginatedTenants, + total, + page, + totalPages, + isLoading: false, + error: null, + }); + } catch (err) { + setState((prev) => ({ + ...prev, + isLoading: false, + error: err instanceof Error ? err.message : 'Error loading tenants', + })); + } + }, [filters]); + + useEffect(() => { + fetchTenants(); + }, [fetchTenants]); + + const createTenant = async (data: CreateTenantDto): Promise => { + await simulateApiDelay(); + + const newTenant: Tenant = { + id: `tenant-${Date.now()}`, + name: data.name, + slug: data.slug, + domain: data.domain ?? null, + subdomain: data.subdomain ?? data.slug, + status: data.startTrial ? 'trial' : 'pending', + planTier: data.planTier || 'starter', + planId: data.planId, + planName: data.planTier ? data.planTier.charAt(0).toUpperCase() + data.planTier.slice(1) : 'Starter', + billingCycle: data.billingCycle || 'monthly', + ownerName: data.ownerName, + ownerEmail: data.ownerEmail, + phone: data.phone ?? null, + timezone: data.timezone || 'UTC', + locale: data.locale || 'en-US', + currency: data.currency || 'USD', + maxUsers: data.maxUsers || 5, + maxBranches: data.maxBranches || 1, + maxStorageGb: data.maxStorageGb || 10, + maxApiCallsMonthly: data.maxApiCallsMonthly || 50000, + enabledModules: data.enabledModules || ['crm', 'sales'], + features: data.features || {}, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + trialEndsAt: data.startTrial + ? new Date(Date.now() + (data.trialDays || 14) * 24 * 60 * 60 * 1000).toISOString() + : null, + }; + + MOCK_TENANTS.unshift(newTenant); + await fetchTenants(); + return newTenant; + }; + + const updateTenant = async (id: string, data: UpdateTenantDto): Promise => { + await simulateApiDelay(); + + const index = MOCK_TENANTS.findIndex((t) => t.id === id); + if (index === -1) { + throw new Error('Tenant not found'); + } + + const existingTenant = MOCK_TENANTS[index]!; + const updatedTenant: Tenant = { + id: existingTenant.id, + name: data.name ?? existingTenant.name, + slug: data.slug ?? existingTenant.slug, + domain: data.domain !== undefined ? data.domain : existingTenant.domain, + subdomain: data.subdomain !== undefined ? data.subdomain : existingTenant.subdomain, + status: existingTenant.status, + planTier: existingTenant.planTier, + planId: existingTenant.planId, + planName: existingTenant.planName, + billingCycle: existingTenant.billingCycle, + ownerName: data.ownerName ?? existingTenant.ownerName, + ownerEmail: data.ownerEmail ?? existingTenant.ownerEmail, + phone: data.phone !== undefined ? data.phone : existingTenant.phone, + timezone: data.timezone ?? existingTenant.timezone, + locale: data.locale ?? existingTenant.locale, + currency: data.currency ?? existingTenant.currency, + maxUsers: data.maxUsers ?? existingTenant.maxUsers, + maxBranches: data.maxBranches ?? existingTenant.maxBranches, + maxStorageGb: data.maxStorageGb ?? existingTenant.maxStorageGb, + maxApiCallsMonthly: data.maxApiCallsMonthly ?? existingTenant.maxApiCallsMonthly, + enabledModules: data.enabledModules ?? existingTenant.enabledModules, + features: data.features ?? existingTenant.features, + createdAt: existingTenant.createdAt, + updatedAt: new Date().toISOString(), + trialEndsAt: existingTenant.trialEndsAt, + suspendedAt: existingTenant.suspendedAt, + suspensionReason: existingTenant.suspensionReason, + cancelledAt: existingTenant.cancelledAt, + cancellationReason: existingTenant.cancellationReason, + lastActiveAt: existingTenant.lastActiveAt, + }; + + MOCK_TENANTS[index] = updatedTenant; + await fetchTenants(); + return updatedTenant; + }; + + const deleteTenant = async (id: string): Promise => { + await simulateApiDelay(); + + const index = MOCK_TENANTS.findIndex((t) => t.id === id); + if (index === -1) { + throw new Error('Tenant not found'); + } + + MOCK_TENANTS.splice(index, 1); + await fetchTenants(); + }; + + const suspendTenant = async (id: string, data: SuspendTenantDto): Promise => { + await simulateApiDelay(); + + const index = MOCK_TENANTS.findIndex((t) => t.id === id); + if (index === -1) { + throw new Error('Tenant not found'); + } + + const existingTenant = MOCK_TENANTS[index]!; + const suspendedTenant: Tenant = { + ...existingTenant, + status: 'suspended', + suspendedAt: new Date().toISOString(), + suspensionReason: data.reason, + updatedAt: new Date().toISOString(), + }; + + MOCK_TENANTS[index] = suspendedTenant; + await fetchTenants(); + return suspendedTenant; + }; + + const activateTenant = async (id: string, _data?: ActivateTenantDto): Promise => { + await simulateApiDelay(); + + const index = MOCK_TENANTS.findIndex((t) => t.id === id); + if (index === -1) { + throw new Error('Tenant not found'); + } + + const existingTenant = MOCK_TENANTS[index]!; + const activatedTenant: Tenant = { + ...existingTenant, + status: 'active', + suspendedAt: null, + suspensionReason: null, + updatedAt: new Date().toISOString(), + }; + + MOCK_TENANTS[index] = activatedTenant; + await fetchTenants(); + return activatedTenant; + }; + + const changePlan = async (id: string, data: ChangeTenantPlanDto): Promise => { + await simulateApiDelay(); + + const index = MOCK_TENANTS.findIndex((t) => t.id === id); + if (index === -1) { + throw new Error('Tenant not found'); + } + + // Simulate plan change + const planTierMap: Record = { + 'plan-free': 'free', + 'plan-starter': 'starter', + 'plan-professional': 'professional', + 'plan-enterprise': 'enterprise', + }; + + const existingTenant = MOCK_TENANTS[index]!; + const changedPlanTenant: Tenant = { + ...existingTenant, + planId: data.planId, + planTier: planTierMap[data.planId] || 'custom', + billingCycle: data.billingCycle || existingTenant.billingCycle, + updatedAt: new Date().toISOString(), + }; + + MOCK_TENANTS[index] = changedPlanTenant; + await fetchTenants(); + return changedPlanTenant; + }; + + const getTenantStats = async (id: string): Promise => { + await simulateApiDelay(300); + + const stats = MOCK_TENANT_STATS[id]; + if (!stats) { + throw new Error('Stats not found for tenant'); + } + + return stats; + }; + + return { + ...state, + filters, + setFilters, + refresh: fetchTenants, + createTenant, + updateTenant, + deleteTenant, + suspendTenant, + activateTenant, + changePlan, + getTenantStats, + }; +} + +// ==================== useTenant Hook (Single Tenant) ==================== + +export function useTenant(id: string | undefined) { + const [tenant, setTenant] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchTenant = useCallback(async () => { + if (!id) { + setIsLoading(false); + return; + } + + setIsLoading(true); + setError(null); + + try { + await simulateApiDelay(); + + const foundTenant = MOCK_TENANTS.find((t) => t.id === id); + if (!foundTenant) { + throw new Error('Tenant not found'); + } + + const stats = MOCK_TENANT_STATS[id] || { + tenantId: id, + totalUsers: 0, + activeUsers: 0, + inactiveUsers: 0, + pendingInvitations: 0, + storageUsedGb: 0, + storageUsedPercent: 0, + apiCallsThisMonth: 0, + apiCallsPercent: 0, + apiCallsLastMonth: 0, + sessionsToday: 0, + sessionsThisWeek: 0, + sessionsThisMonth: 0, + avgSessionDurationMinutes: 0, + totalBranches: 0, + totalDocuments: 0, + totalTransactions: 0, + periodStart: new Date().toISOString(), + periodEnd: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + setTenant({ ...foundTenant, stats }); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error loading tenant'); + } finally { + setIsLoading(false); + } + }, [id]); + + useEffect(() => { + fetchTenant(); + }, [fetchTenant]); + + return { tenant, isLoading, error, refresh: fetchTenant }; +} + +// ==================== useTenantUsageHistory Hook ==================== + +export function useTenantUsageHistory(tenantId: string | undefined, days = 30) { + const [history, setHistory] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!tenantId) { + setIsLoading(false); + return; + } + + const fetchHistory = async () => { + setIsLoading(true); + setError(null); + + try { + await simulateApiDelay(400); + + // Generate mock history data + const mockHistory: TenantUsageHistory[] = []; + const now = new Date(); + + for (let i = days - 1; i >= 0; i--) { + const date = new Date(now); + date.setDate(date.getDate() - i); + const dateStr = date.toISOString().split('T')[0] ?? date.toISOString().slice(0, 10); + + mockHistory.push({ + date: dateStr, + activeUsers: Math.floor(Math.random() * 50) + 10, + apiCalls: Math.floor(Math.random() * 10000) + 1000, + storageGb: Math.random() * 10 + 40, + sessions: Math.floor(Math.random() * 100) + 20, + }); + } + + setHistory(mockHistory); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error loading usage history'); + } finally { + setIsLoading(false); + } + }; + + fetchHistory(); + }, [tenantId, days]); + + return { history, isLoading, error }; +} + +// ==================== useTenantSubscription Hook ==================== + +export function useTenantSubscription(tenantId: string | undefined) { + const [subscription, setSubscription] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!tenantId) { + setIsLoading(false); + return; + } + + const fetchSubscription = async () => { + setIsLoading(true); + setError(null); + + try { + await simulateApiDelay(300); + + const tenant = MOCK_TENANTS.find((t) => t.id === tenantId); + if (!tenant) { + throw new Error('Tenant not found'); + } + + // Generate mock subscription + const mockSubscription: TenantSubscription = { + id: `sub-${tenantId}`, + tenantId, + planId: tenant.planId || 'plan-starter', + planName: tenant.planName || 'Starter', + planTier: tenant.planTier, + billingCycle: tenant.billingCycle, + basePrice: tenant.planTier === 'enterprise' ? 999 : tenant.planTier === 'professional' ? 299 : 49, + currentPrice: tenant.planTier === 'enterprise' ? 899 : tenant.planTier === 'professional' ? 299 : 49, + discountPercent: tenant.planTier === 'enterprise' ? 10 : 0, + discountReason: tenant.planTier === 'enterprise' ? 'Annual commitment discount' : null, + currency: tenant.currency || 'USD', + currentPeriodStart: '2026-02-01T00:00:00Z', + currentPeriodEnd: tenant.billingCycle === 'annual' ? '2027-02-01T00:00:00Z' : '2026-03-01T00:00:00Z', + nextBillingDate: tenant.billingCycle === 'annual' ? '2027-02-01T00:00:00Z' : '2026-03-01T00:00:00Z', + isTrialing: tenant.status === 'trial', + trialStart: tenant.status === 'trial' ? tenant.createdAt : null, + trialEnd: tenant.trialEndsAt, + paymentMethod: tenant.status !== 'trial' ? 'card_visa_4242' : null, + lastPaymentAt: tenant.status === 'active' ? '2026-02-01T10:00:00Z' : null, + lastPaymentAmount: tenant.planTier === 'enterprise' ? 899 : tenant.planTier === 'professional' ? 299 : 49, + lastPaymentStatus: tenant.status === 'active' ? 'succeeded' : null, + contractedUsers: tenant.maxUsers, + contractedBranches: tenant.maxBranches, + contractedStorageGb: tenant.maxStorageGb, + status: tenant.status === 'trial' ? 'trialing' : tenant.status === 'suspended' ? 'past_due' : 'active', + autoRenew: true, + cancelAtPeriodEnd: false, + stripeCustomerId: tenant.status !== 'trial' ? `cus_${tenantId.replace('tenant-', '')}` : null, + stripeSubscriptionId: tenant.status !== 'trial' ? `sub_${tenantId.replace('tenant-', '')}` : null, + createdAt: tenant.createdAt, + updatedAt: tenant.updatedAt, + }; + + setSubscription(mockSubscription); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error loading subscription'); + } finally { + setIsLoading(false); + } + }; + + fetchSubscription(); + }, [tenantId]); + + return { subscription, isLoading, error }; +} diff --git a/src/features/superadmin/index.ts b/src/features/superadmin/index.ts new file mode 100644 index 0000000..3a54535 --- /dev/null +++ b/src/features/superadmin/index.ts @@ -0,0 +1,13 @@ +/** + * Superadmin Feature - Index + * Exports all public API for the superadmin portal + */ + +// Hooks +export * from './hooks'; + +// Types +export * from './types'; + +// API +export * from './api'; diff --git a/src/features/superadmin/types/index.ts b/src/features/superadmin/types/index.ts new file mode 100644 index 0000000..c8a0987 --- /dev/null +++ b/src/features/superadmin/types/index.ts @@ -0,0 +1,6 @@ +/** + * Superadmin Types - Index + * Exports all types for the superadmin portal + */ + +export * from './tenant'; diff --git a/src/features/superadmin/types/tenant.ts b/src/features/superadmin/types/tenant.ts new file mode 100644 index 0000000..505f90f --- /dev/null +++ b/src/features/superadmin/types/tenant.ts @@ -0,0 +1,282 @@ +/** + * Tenant Management Types + * Superadmin Portal - ERP Core + */ + +// ==================== Enums ==================== + +export type TenantStatus = 'active' | 'suspended' | 'pending' | 'cancelled' | 'trial'; +export type TenantPlanTier = 'free' | 'starter' | 'professional' | 'enterprise' | 'custom'; +export type BillingCycle = 'monthly' | 'annual'; + +// ==================== Main Tenant Interface ==================== + +export interface Tenant { + id: string; + name: string; + slug: string; + domain?: string | null; + subdomain?: string | null; + status: TenantStatus; + planTier: TenantPlanTier; + planId?: string | null; + planName?: string | null; + billingCycle: BillingCycle; + + // Contact Information + ownerName: string; + ownerEmail: string; + phone?: string | null; + + // Settings + timezone?: string; + locale?: string; + currency?: string; + + // Limits + maxUsers: number; + maxBranches: number; + maxStorageGb: number; + maxApiCallsMonthly: number; + + // Feature Flags + enabledModules: string[]; + features: Record; + + // Timestamps + createdAt: string; + updatedAt: string; + trialEndsAt?: string | null; + suspendedAt?: string | null; + suspensionReason?: string | null; + cancelledAt?: string | null; + cancellationReason?: string | null; + lastActiveAt?: string | null; +} + +// ==================== Tenant Statistics ==================== + +export interface TenantStats { + tenantId: string; + + // User Metrics + totalUsers: number; + activeUsers: number; + inactiveUsers: number; + pendingInvitations: number; + + // Storage Metrics + storageUsedGb: number; + storageUsedPercent: number; + + // API Usage + apiCallsThisMonth: number; + apiCallsPercent: number; + apiCallsLastMonth: number; + + // Activity Metrics + sessionsToday: number; + sessionsThisWeek: number; + sessionsThisMonth: number; + avgSessionDurationMinutes: number; + + // Business Metrics + totalBranches: number; + totalDocuments: number; + totalTransactions: number; + + // Period + periodStart: string; + periodEnd: string; + updatedAt: string; +} + +export interface TenantUsageHistory { + date: string; + activeUsers: number; + apiCalls: number; + storageGb: number; + sessions: number; +} + +// ==================== Tenant Subscription ==================== + +export interface TenantSubscription { + id: string; + tenantId: string; + planId: string; + planName: string; + planTier: TenantPlanTier; + billingCycle: BillingCycle; + + // Pricing + basePrice: number; + currentPrice: number; + discountPercent: number; + discountReason?: string | null; + currency: string; + + // Period + currentPeriodStart: string; + currentPeriodEnd: string; + nextBillingDate?: string | null; + + // Trial + isTrialing: boolean; + trialStart?: string | null; + trialEnd?: string | null; + + // Payment + paymentMethod?: string | null; + lastPaymentAt?: string | null; + lastPaymentAmount?: number | null; + lastPaymentStatus?: 'succeeded' | 'failed' | 'pending' | null; + + // Contracted Limits + contractedUsers: number; + contractedBranches: number; + contractedStorageGb: number; + + // Status + status: 'active' | 'past_due' | 'cancelled' | 'trialing' | 'incomplete'; + autoRenew: boolean; + cancelAtPeriodEnd: boolean; + + // External IDs + stripeCustomerId?: string | null; + stripeSubscriptionId?: string | null; + + createdAt: string; + updatedAt: string; +} + +// ==================== DTOs ==================== + +export interface CreateTenantDto { + name: string; + slug: string; + domain?: string; + subdomain?: string; + + // Owner + ownerName: string; + ownerEmail: string; + ownerPassword?: string; + phone?: string; + + // Plan + planId?: string; + planTier?: TenantPlanTier; + billingCycle?: BillingCycle; + + // Settings + timezone?: string; + locale?: string; + currency?: string; + + // Trial + startTrial?: boolean; + trialDays?: number; + + // Limits (override plan defaults) + maxUsers?: number; + maxBranches?: number; + maxStorageGb?: number; + maxApiCallsMonthly?: number; + + // Features + enabledModules?: string[]; + features?: Record; +} + +export interface UpdateTenantDto { + name?: string; + slug?: string; + domain?: string | null; + subdomain?: string | null; + + // Contact + ownerName?: string; + ownerEmail?: string; + phone?: string | null; + + // Settings + timezone?: string; + locale?: string; + currency?: string; + + // Limits + maxUsers?: number; + maxBranches?: number; + maxStorageGb?: number; + maxApiCallsMonthly?: number; + + // Features + enabledModules?: string[]; + features?: Record; +} + +export interface SuspendTenantDto { + reason: string; + suspendImmediately?: boolean; + notifyOwner?: boolean; +} + +export interface ActivateTenantDto { + reason?: string; + notifyOwner?: boolean; +} + +export interface ChangeTenantPlanDto { + planId: string; + billingCycle?: BillingCycle; + effectiveDate?: string; + prorateCharges?: boolean; + contractedUsers?: number; + contractedBranches?: number; +} + +// ==================== Filters and Responses ==================== + +export interface TenantFilters { + search?: string; + status?: TenantStatus; + planTier?: TenantPlanTier; + billingCycle?: BillingCycle; + hasOverduePayment?: boolean; + createdAfter?: string; + createdBefore?: string; + page?: number; + limit?: number; + sortBy?: 'name' | 'createdAt' | 'lastActiveAt' | 'status' | 'planTier'; + sortOrder?: 'asc' | 'desc'; +} + +export interface TenantsResponse { + data: Tenant[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; +} + +export interface TenantWithStats extends Tenant { + stats: TenantStats; + subscription?: TenantSubscription; +} + +// ==================== Activity ==================== + +export interface TenantActivity { + id: string; + tenantId: string; + tenantName: string; + type: 'created' | 'updated' | 'suspended' | 'activated' | 'plan_changed' | 'payment_received' | 'payment_failed' | 'user_added' | 'user_removed'; + description: string; + metadata?: Record; + performedBy?: string; + performedByName?: string; + createdAt: string; +} diff --git a/src/pages/superadmin/SuperadminDashboardPage.tsx b/src/pages/superadmin/SuperadminDashboardPage.tsx new file mode 100644 index 0000000..cc99e8d --- /dev/null +++ b/src/pages/superadmin/SuperadminDashboardPage.tsx @@ -0,0 +1,771 @@ +/** + * SuperadminDashboardPage + * Main dashboard for superadmin portal with stats, charts, activity feed, and system health + */ + +import React from 'react'; +import { + Building2, + CreditCard, + DollarSign, + Users, + TrendingUp, + TrendingDown, + Activity, + AlertTriangle, + CheckCircle, + Clock, + RefreshCw, + ArrowUpRight, + UserPlus, + ArrowUp, + ArrowDown, + Zap, + HardDrive, + Server, +} from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@components/molecules/Card'; +import { Badge } from '@components/atoms/Badge'; +import { Spinner } from '@components/atoms/Spinner'; +import { Button } from '@components/atoms/Button'; +import { + useSuperadminStats, + type PlanDistribution, + type RecentTenantActivity, + type TopTenant, + type SystemHealthMetrics, +} from '@features/superadmin/hooks'; +import { formatCurrency, formatRelativeTime, formatNumber } from '@utils/formatters'; + +// ==================== Helper Functions ==================== + +function formatLargeNumber(value: number): string { + if (value >= 1000000) { + return `${(value / 1000000).toFixed(1)}M`; + } + if (value >= 1000) { + return `${(value / 1000).toFixed(1)}K`; + } + return value.toString(); +} + +function getChangeColor(change: number): string { + if (change > 0) return 'text-success-600'; + if (change < 0) return 'text-danger-600'; + return 'text-gray-500'; +} + +function getActivityIcon(iconType: RecentTenantActivity['icon']): React.ReactNode { + const iconClass = 'h-4 w-4'; + switch (iconType) { + case 'signup': + return ; + case 'upgrade': + return ; + case 'downgrade': + return ; + case 'suspension': + return ; + case 'payment': + return ; + default: + return ; + } +} + +function getTrendIcon(trend: 'up' | 'down' | 'stable'): React.ReactNode { + switch (trend) { + case 'up': + return ; + case 'down': + return ; + default: + return ; + } +} + +function getPlanBadgeVariant(plan: string): 'default' | 'primary' | 'success' | 'warning' | 'danger' { + switch (plan) { + case 'enterprise': + return 'warning'; + case 'professional': + return 'primary'; + case 'starter': + return 'success'; + default: + return 'default'; + } +} + +// ==================== Sub-Components ==================== + +interface StatCardProps { + title: string; + value: string | number; + change?: number; + changeLabel?: string; + icon: React.ReactNode; + format?: 'number' | 'currency' | 'percent'; +} + +function StatCard({ title, value, change, changeLabel = 'vs mes anterior', icon, format = 'number' }: StatCardProps) { + const formattedValue = format === 'currency' + ? formatCurrency(value as number) + : format === 'percent' + ? `${value}%` + : formatLargeNumber(value as number); + + return ( + + +
+
+

{title}

+

{formattedValue}

+
+
+ {icon} +
+
+ {change !== undefined && ( +
+ {change > 0 ? ( + + ) : change < 0 ? ( + + ) : null} + + {change > 0 ? '+' : ''}{change}% + + {changeLabel} +
+ )} +
+
+ ); +} + +interface WidgetContainerProps { + title: string; + children: React.ReactNode; + action?: React.ReactNode; + className?: string; +} + +function WidgetContainer({ title, children, action, className = '' }: WidgetContainerProps) { + return ( + + + {title} + {action} + + {children} + + ); +} + +// Chart placeholder components (to be replaced with actual chart library) +interface LineChartPlaceholderProps { + data: Array<{ month: string; totalTenants: number }>; +} + +function LineChartPlaceholder({ data }: LineChartPlaceholderProps) { + const max = Math.max(...data.map((d) => d.totalTenants)); + const min = Math.min(...data.map((d) => d.totalTenants)); + const range = max - min || 1; + + return ( +
+
+ {data.map((point) => { + const height = ((point.totalTenants - min) / range) * 100; + const normalizedHeight = Math.max(height, 10); + return ( +
+
+ {point.month} +
+ ); + })} +
+
+
+
+ Total Tenants +
+
+
+ ); +} + +interface PieChartPlaceholderProps { + data: PlanDistribution[]; +} + +function PieChartPlaceholder({ data }: PieChartPlaceholderProps) { + const total = data.reduce((acc, item) => acc + item.count, 0); + + return ( +
+
+ {/* Visual representation */} +
+
+ + {data.reduce( + (acc, item) => { + const angle = (item.count / total) * 360; + const startAngle = acc.currentAngle; + const endAngle = startAngle + angle; + + const x1 = 50 + 40 * Math.cos((Math.PI * (startAngle - 90)) / 180); + const y1 = 50 + 40 * Math.sin((Math.PI * (startAngle - 90)) / 180); + const x2 = 50 + 40 * Math.cos((Math.PI * (endAngle - 90)) / 180); + const y2 = 50 + 40 * Math.sin((Math.PI * (endAngle - 90)) / 180); + + const largeArc = angle > 180 ? 1 : 0; + + const path = ( + + {`${item.planLabel}: ${item.count} (${item.percentage}%)`} + + ); + + acc.paths.push(path); + acc.currentAngle = endAngle; + return acc; + }, + { paths: [] as React.ReactNode[], currentAngle: 0 } + ).paths} + +
+
+ {/* Legend */} +
+ {data.map((item) => ( +
+
+ {item.planLabel} + {item.percentage}% +
+ ))} +
+
+
+ ); +} + +interface AreaChartPlaceholderProps { + data: Array<{ month: string; revenue: number }>; +} + +function AreaChartPlaceholder({ data }: AreaChartPlaceholderProps) { + const max = Math.max(...data.map((d) => d.revenue)); + const min = Math.min(...data.map((d) => d.revenue)) * 0.9; + const range = max - min || 1; + + return ( +
+
+ {/* Area gradient background */} +
+ + + + + + + + { + const x = (i / (data.length - 1)) * 100; + const y = 100 - ((point.revenue - min) / range) * 100; + return `L ${x} ${y}`; + }) + .join(' ')} L 100 100 L 0 100 Z`} + fill="url(#areaGradient)" + /> + +
+ {/* Bars */} + {data.map((point) => { + const height = ((point.revenue - min) / range) * 100; + return ( +
+
+ {point.month} +
+ ); + })} +
+
+ ); +} + +interface RecentActivityFeedProps { + activities: RecentTenantActivity[]; +} + +function RecentActivityFeed({ activities }: RecentActivityFeedProps) { + if (activities.length === 0) { + return ( +
+ No hay actividad reciente +
+ ); + } + + return ( +
+ {activities.map((activity) => ( +
+
+ {getActivityIcon(activity.icon)} +
+
+

{activity.tenantName}

+

{activity.description}

+

+ + {formatRelativeTime(activity.timestamp)} +

+
+
+ ))} +
+ ); +} + +interface TopTenantsTableProps { + tenants: TopTenant[]; +} + +function TopTenantsTable({ tenants }: TopTenantsTableProps) { + if (tenants.length === 0) { + return ( +
+ No hay datos de tenants +
+ ); + } + + return ( +
+ + + + + + + + + + + + {tenants.map((tenant, index) => ( + + + + + + + + ))} + +
+ Tenant + + Plan + + Usuarios + + MRR + + Storage +
+
+
+ {index + 1} +
+
+

{tenant.name}

+

{tenant.slug}

+
+
+
+ + {tenant.plan.charAt(0).toUpperCase() + tenant.plan.slice(1)} + + + {formatNumber(tenant.usersCount)} + + {formatCurrency(tenant.monthlyRevenue)} + + {tenant.storageUsedGb.toFixed(1)} GB +
+
+ ); +} + +interface SystemHealthCardProps { + health: SystemHealthMetrics | null; + onRefresh: () => void; +} + +function SystemHealthCard({ health, onRefresh }: SystemHealthCardProps) { + if (!health) { + return ( + + + + + + ); + } + + const getHealthStatus = () => { + if (health.errorRate > 1 || health.uptimePercent < 99) { + return { status: 'warning', label: 'Degradado', color: 'text-warning-600', bg: 'bg-warning-100' }; + } + if (health.errorRate > 5 || health.uptimePercent < 95) { + return { status: 'critical', label: 'Critico', color: 'text-danger-600', bg: 'bg-danger-100' }; + } + return { status: 'healthy', label: 'Saludable', color: 'text-success-600', bg: 'bg-success-100' }; + }; + + const statusInfo = getHealthStatus(); + + return ( + + + Estado del Sistema + + + + {/* Overall Status */} +
+
+ {statusInfo.status === 'healthy' ? ( + + ) : ( + + )} + {statusInfo.label} +
+ + Uptime: {health.uptimePercent}% + +
+ + {/* Metrics Grid */} +
+ {/* API Response Time */} +
+
+
+ + Resp. Time +
+ {getTrendIcon(health.apiResponseTimeTrend)} +
+

{health.apiResponseTimeMs}ms

+
+ + {/* Error Rate */} +
+
+
+ + Error Rate +
+ {getTrendIcon(health.errorRateTrend)} +
+

{health.errorRate}%

+
+ + {/* Active Connections */} +
+
+ + Conexiones +
+

+ {formatNumber(health.activeConnections)} +

+
+ + {/* Queued Jobs */} +
+
+ + Jobs en Cola +
+

{health.queuedJobs}

+
+
+
+
+ ); +} + +// ==================== Main Component ==================== + +export function SuperadminDashboardPage() { + const { + overview, + tenantGrowth, + planDistribution, + revenueTrend, + recentActivity, + topTenants, + systemHealth, + isLoading, + error, + refresh, + refreshSystemHealth, + } = useSuperadminStats(); + + // Format current date + const currentDate = new Date().toLocaleDateString('es-MX', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }); + + if (isLoading) { + return ( +
+
+ +

Cargando dashboard...

+
+
+ ); + } + + if (error) { + return ( +
+
+ +

Error al cargar datos

+

{error}

+ +
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+
+

Panel de Superadmin

+

{currentDate}

+
+ +
+ + {/* Key Metrics Row */} +
+ } + /> + } + /> + } + format="currency" + /> + } + /> +
+ + {/* Charts Row */} +
+ {/* Tenant Growth Chart */} + Ultimos 12 meses + } + > + + + + {/* Plan Distribution Chart */} + + + + + {/* Revenue Trend Chart */} + Ultimos 12 meses + } + > + + +
+ + {/* Activity & Top Tenants Row */} +
+ {/* Recent Activity Feed */} + + Ver todo + + + } + > + + + + {/* Top Tenants Table */} + + Ver todos + + + } + > + + +
+ + {/* System Health & Quick Stats Row */} +
+ {/* System Health */} +
+ +
+ + {/* Additional Stats */} +
+ + +
+
+ +
+
+

En Periodo de Prueba

+

+ {overview?.trialTenants || 0} +

+
+
+
+
+ + + +
+
+ +
+
+

Tenants Suspendidos

+

+ {overview?.suspendedTenants || 0} +

+
+
+
+
+ + + +

Acciones Rapidas

+ +
+
+
+
+
+
+ ); +} + +export default SuperadminDashboardPage; diff --git a/src/pages/superadmin/index.ts b/src/pages/superadmin/index.ts new file mode 100644 index 0000000..6266bc1 --- /dev/null +++ b/src/pages/superadmin/index.ts @@ -0,0 +1,14 @@ +/** + * Superadmin Pages - Index + * Exports all pages for the superadmin portal + */ + +export { SuperadminDashboardPage } from './SuperadminDashboardPage'; + +// Tenant Management +export { + TenantsListPage, + TenantDetailPage, + TenantCreatePage, + TenantEditPage, +} from './tenants'; diff --git a/src/pages/superadmin/tenants/TenantCreatePage.tsx b/src/pages/superadmin/tenants/TenantCreatePage.tsx new file mode 100644 index 0000000..6c5ae29 --- /dev/null +++ b/src/pages/superadmin/tenants/TenantCreatePage.tsx @@ -0,0 +1,683 @@ +/** + * TenantCreatePage - Superadmin Portal + * Create a new tenant in the system + */ + +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { ArrowLeft, Building2 } from 'lucide-react'; +import { Button } from '@components/atoms/Button'; +import { Input } from '@components/atoms/Input'; +import { Label } from '@components/atoms/Label'; +import { Switch } from '@components/atoms/Switch'; +import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card'; +import { FormField } from '@components/molecules/FormField'; +import { Alert } from '@components/molecules/Alert'; +import { Select } from '@components/organisms/Select'; +import { Breadcrumbs } from '@components/organisms/Breadcrumbs'; +import { useToast } from '@components/organisms/Toast'; +import { useTenants } from '@features/superadmin'; +import type { CreateTenantDto, TenantPlanTier, BillingCycle } from '@features/superadmin'; + +// ==================== Form Types ==================== + +interface FormData { + name: string; + slug: string; + domain: string; + subdomain: string; + ownerName: string; + ownerEmail: string; + ownerPassword: string; + phone: string; + planTier: TenantPlanTier; + billingCycle: BillingCycle; + timezone: string; + locale: string; + currency: string; + startTrial: boolean; + trialDays: number; + maxUsers: number; + maxBranches: number; + maxStorageGb: number; + // Features + advancedReporting: boolean; + apiAccess: boolean; + customBranding: boolean; + sso: boolean; + // Modules + moduleCrm: boolean; + moduleInventory: boolean; + moduleSales: boolean; + modulePurchases: boolean; + moduleHr: boolean; + moduleAccounting: boolean; + moduleProjects: boolean; +} + +// ==================== Options ==================== + +const PLAN_OPTIONS = [ + { value: 'free', label: 'Gratis' }, + { value: 'starter', label: 'Starter - $49/mes' }, + { value: 'professional', label: 'Professional - $299/mes' }, + { value: 'enterprise', label: 'Enterprise - $999/mes' }, + { value: 'custom', label: 'Personalizado' }, +]; + +const BILLING_OPTIONS = [ + { value: 'monthly', label: 'Mensual' }, + { value: 'annual', label: 'Anual (10% descuento)' }, +]; + +const TIMEZONE_OPTIONS = [ + { value: 'America/Mexico_City', label: 'America/Mexico_City (UTC-6)' }, + { value: 'America/New_York', label: 'America/New_York (UTC-5)' }, + { value: 'America/Los_Angeles', label: 'America/Los_Angeles (UTC-8)' }, + { value: 'America/Chicago', label: 'America/Chicago (UTC-6)' }, + { value: 'Europe/London', label: 'Europe/London (UTC+0)' }, + { value: 'Europe/Madrid', label: 'Europe/Madrid (UTC+1)' }, + { value: 'Asia/Shanghai', label: 'Asia/Shanghai (UTC+8)' }, + { value: 'UTC', label: 'UTC' }, +]; + +const LOCALE_OPTIONS = [ + { value: 'es-MX', label: 'Espanol (Mexico)' }, + { value: 'es-ES', label: 'Espanol (Espana)' }, + { value: 'en-US', label: 'English (US)' }, + { value: 'en-GB', label: 'English (UK)' }, + { value: 'pt-BR', label: 'Portugues (Brasil)' }, + { value: 'zh-CN', label: 'Chino (Simplificado)' }, +]; + +const CURRENCY_OPTIONS = [ + { value: 'MXN', label: 'MXN - Peso Mexicano' }, + { value: 'USD', label: 'USD - Dolar Americano' }, + { value: 'EUR', label: 'EUR - Euro' }, + { value: 'GBP', label: 'GBP - Libra Esterlina' }, + { value: 'CNY', label: 'CNY - Yuan Chino' }, +]; + +// ==================== Main Component ==================== + +export function TenantCreatePage() { + const navigate = useNavigate(); + const { showToast } = useToast(); + const { createTenant } = useTenants(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + const { + register, + handleSubmit, + watch, + setValue, + formState: { errors }, + } = useForm({ + defaultValues: { + name: '', + slug: '', + domain: '', + subdomain: '', + ownerName: '', + ownerEmail: '', + ownerPassword: '', + phone: '', + planTier: 'starter', + billingCycle: 'monthly', + timezone: 'America/Mexico_City', + locale: 'es-MX', + currency: 'MXN', + startTrial: true, + trialDays: 14, + maxUsers: 10, + maxBranches: 1, + maxStorageGb: 10, + advancedReporting: false, + apiAccess: false, + customBranding: false, + sso: false, + moduleCrm: true, + moduleInventory: true, + moduleSales: true, + modulePurchases: false, + moduleHr: false, + moduleAccounting: false, + moduleProjects: false, + }, + }); + + const startTrial = watch('startTrial'); + + // Auto-generate slug from name + const handleNameChange = (e: React.ChangeEvent) => { + const newName = e.target.value; + const slug = newName + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); + setValue('slug', slug); + setValue('subdomain', slug); + }; + + // Update limits based on plan + const updatePlanDefaults = (tier: TenantPlanTier) => { + const planDefaults: Record = { + free: { users: 3, branches: 1, storage: 1 }, + starter: { users: 10, branches: 1, storage: 10 }, + professional: { users: 50, branches: 10, storage: 100 }, + enterprise: { users: 200, branches: 50, storage: 500 }, + custom: { users: 100, branches: 25, storage: 250 }, + }; + const defaults = planDefaults[tier]; + setValue('maxUsers', defaults.users); + setValue('maxBranches', defaults.branches); + setValue('maxStorageGb', defaults.storage); + + // Update features based on plan + if (tier === 'enterprise' || tier === 'custom') { + setValue('advancedReporting', true); + setValue('apiAccess', true); + setValue('customBranding', true); + setValue('sso', true); + } else if (tier === 'professional') { + setValue('advancedReporting', true); + setValue('apiAccess', true); + setValue('customBranding', false); + setValue('sso', false); + } else { + setValue('advancedReporting', false); + setValue('apiAccess', false); + setValue('customBranding', false); + setValue('sso', false); + } + }; + + const onFormSubmit = async (data: FormData) => { + setIsSubmitting(true); + setError(null); + + try { + // Build enabled modules array + const enabledModules: string[] = []; + if (data.moduleCrm) enabledModules.push('crm'); + if (data.moduleInventory) enabledModules.push('inventory'); + if (data.moduleSales) enabledModules.push('sales'); + if (data.modulePurchases) enabledModules.push('purchases'); + if (data.moduleHr) enabledModules.push('hr'); + if (data.moduleAccounting) enabledModules.push('accounting'); + if (data.moduleProjects) enabledModules.push('projects'); + + const createData: CreateTenantDto = { + name: data.name, + slug: data.slug, + domain: data.domain || undefined, + subdomain: data.subdomain || data.slug, + ownerName: data.ownerName, + ownerEmail: data.ownerEmail, + ownerPassword: data.ownerPassword || undefined, + phone: data.phone || undefined, + planTier: data.planTier, + billingCycle: data.billingCycle, + timezone: data.timezone, + locale: data.locale, + currency: data.currency, + startTrial: data.startTrial, + trialDays: data.startTrial ? data.trialDays : undefined, + maxUsers: data.maxUsers, + maxBranches: data.maxBranches, + maxStorageGb: data.maxStorageGb, + enabledModules, + features: { + advancedReporting: data.advancedReporting, + apiAccess: data.apiAccess, + customBranding: data.customBranding, + sso: data.sso, + }, + }; + + const tenant = await createTenant(createData); + showToast({ + type: 'success', + title: 'Tenant creado', + message: `${tenant.name} ha sido creado exitosamente. Se ha enviado un email de invitacion al propietario.`, + }); + navigate('/superadmin/tenants'); + } catch (err) { + const message = err instanceof Error ? err.message : 'Error al crear tenant'; + setError(message); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ {/* Breadcrumbs */} + + + {/* Header */} +
+ +
+
+ +
+
+

Nuevo tenant

+

+ Crea una nueva organizacion en la plataforma +

+
+
+
+ + {/* Form */} +
+
+ {/* Main form */} +
+ {error && ( + setError(null)} + > + {error} + + )} + + {/* Basic Information */} + + + Informacion basica + + +
+ + { + register('name').onChange(e); + handleNameChange(e); + }} + /> + + + + + +
+ +
+ + + + + + + +
+
+
+ + {/* Owner Information */} + + + Propietario + + + + + + +
+ + + + + + + +
+ + + + +
+
+ + {/* Plan & Billing */} + + + Plan y Facturacion + + +
+ + setValue('billingCycle', value as BillingCycle)} + /> + +
+ +
+
+ +

El tenant tendra acceso completo durante el periodo de prueba

+
+ setValue('startTrial', checked)} + /> +
+ + {startTrial && ( + + + + )} +
+
+ + {/* Limits */} + + + Limites + + +
+ + + + + + + + + + + +
+
+
+ + {/* Regional Settings */} + + + Configuracion regional + + +
+ + setValue('locale', value as string)} + /> + + + + + + + + + +
+ +
+ + + + + + + +
+
+
+ + {/* Owner Information */} + + + Propietario + + + + + + +
+ + + + + + + +
+
+
+ + {/* Limits */} + + + Limites + + +
+ + + + + + + + + + + + + + + +
+
+
+ + {/* Regional Settings */} + + + Configuracion regional + + +
+ + setValue('locale', value as string)} + /> + + + + setSearchInput(e.target.value)} + onKeyDown={handleSearchKeyDown} + className="pl-10" + /> +
+ + {/* Status Filter */} +
+ +
+ + {/* Search Button */} + +
+ + {/* Table or Empty State */} + {tenants.length === 0 && !isLoading ? ( + hasFilters ? ( + + ) : ( + navigate('/superadmin/tenants/new')} + /> + ) + ) : ( + + )} +
+ + + + {/* Delete Confirmation Modal */} + setTenantToDelete(null)} + onConfirm={handleDeleteConfirm} + title="Eliminar tenant" + message={`¿Estas seguro de que deseas eliminar "${tenantToDelete?.name}"? Esta accion eliminara todos los datos asociados y no se puede deshacer.`} + variant="danger" + confirmText="Eliminar" + /> + + {/* Suspend Confirmation Modal */} + setTenantToSuspend(null)} + onConfirm={handleSuspendConfirm} + title="Suspender tenant" + message={`¿Estas seguro de que deseas suspender "${tenantToSuspend?.name}"? Los usuarios no podran acceder hasta que se reactive.`} + variant="danger" + confirmText="Suspender" + /> + + {/* Activate Confirmation Modal */} + setTenantToActivate(null)} + onConfirm={handleActivateConfirm} + title="Reactivar tenant" + message={`¿Deseas reactivar "${tenantToActivate?.name}"? Los usuarios podran acceder nuevamente.`} + variant="success" + confirmText="Reactivar" + /> +
+ ); +} + +export default TenantsListPage; diff --git a/src/pages/superadmin/tenants/index.ts b/src/pages/superadmin/tenants/index.ts new file mode 100644 index 0000000..4feda7a --- /dev/null +++ b/src/pages/superadmin/tenants/index.ts @@ -0,0 +1,9 @@ +/** + * Superadmin Tenants Pages - Index + * Exports all tenant management pages + */ + +export { TenantsListPage } from './TenantsListPage'; +export { TenantDetailPage } from './TenantDetailPage'; +export { TenantCreatePage } from './TenantCreatePage'; +export { TenantEditPage } from './TenantEditPage';