[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 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-03 20:44:29 -06:00
parent b6dd94abcb
commit 29c76fcbd6
18 changed files with 5655 additions and 0 deletions

View File

@ -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 (
<button
onClick={open}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-1.5',
'border border-indigo-200 bg-indigo-50 text-indigo-600',
'hover:bg-indigo-100 hover:text-indigo-700',
'dark:border-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400',
'dark:hover:bg-indigo-800/40 dark:hover:text-indigo-300',
'transition-colors duration-150'
)}
aria-label="Buscar"
>
<Search className="h-4 w-4" />
<span className="hidden text-sm sm:inline">Buscar...</span>
<kbd className="hidden rounded border border-indigo-300 bg-white px-1.5 py-0.5 text-[10px] font-medium text-indigo-600 sm:inline dark:border-indigo-600 dark:bg-indigo-900/50 dark:text-indigo-400">
{isMac ? '⌘' : 'Ctrl'}+K
</kbd>
</button>
);
}
/**
* 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 (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Mobile sidebar backdrop */}
{isMobile && sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-indigo-900/75 dark:bg-gray-900/80"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={cn(
'fixed inset-y-0 left-0 z-50 flex flex-col bg-gradient-to-b from-indigo-900 to-purple-900 shadow-lg transition-all duration-300 dark:from-gray-900 dark:to-gray-800 dark:shadow-gray-900/50',
isMobile
? sidebarOpen
? 'translate-x-0'
: '-translate-x-full'
: sidebarCollapsed
? 'w-16'
: 'w-64'
)}
>
{/* Logo */}
<div className="flex h-16 items-center justify-between border-b border-indigo-700/50 px-4 dark:border-gray-700">
{(!sidebarCollapsed || isMobile) && (
<Link to="/superadmin/dashboard" className="flex items-center">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-purple-500">
<Shield className="h-5 w-5 text-white" />
</div>
<div className="ml-2">
<span className="text-lg font-bold text-white">Superadmin</span>
<span className="ml-1 text-xs text-indigo-300 dark:text-indigo-400">Portal</span>
</div>
</Link>
)}
{sidebarCollapsed && !isMobile && (
<Link to="/superadmin/dashboard" className="flex items-center justify-center w-full">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-purple-500">
<Shield className="h-5 w-5 text-white" />
</div>
</Link>
)}
{isMobile && (
<button onClick={() => setSidebarOpen(false)} className="p-2 text-indigo-300 hover:text-white" aria-label="Cerrar menú">
<X className="h-5 w-5" />
</button>
)}
</div>
{/* Navigation */}
<nav className="flex-1 space-y-1 overflow-y-auto p-2">
{superadminNavigation.map((item) => {
const isActive = isParentActive(item);
const Icon = item.icon;
const hasChildren = item.children && item.children.length > 0;
return (
<div key={item.name}>
<Link
to={hasChildren && item.children?.[0] ? item.children[0].href : item.href}
className={cn(
'flex items-center rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-indigo-800/60 text-white dark:bg-indigo-900/40 dark:text-indigo-300'
: 'text-indigo-200 hover:bg-indigo-800/40 hover:text-white dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white'
)}
>
<Icon
className={cn(
'h-5 w-5 flex-shrink-0',
isActive
? 'text-purple-300 dark:text-purple-400'
: 'text-indigo-400 dark:text-gray-500'
)}
/>
{(!sidebarCollapsed || isMobile) && (
<>
<span className="ml-3">{item.name}</span>
{item.badge && (
<span className="ml-auto inline-flex items-center rounded-full bg-purple-500 px-2 py-0.5 text-xs font-medium text-white">
{item.badge}
</span>
)}
</>
)}
</Link>
{/* Sub-navigation items */}
{hasChildren && isActive && (!sidebarCollapsed || isMobile) && (
<div className="mt-1 ml-4 space-y-1">
{item.children!.map((child) => {
const isChildActive = isItemActive(child.href);
const ChildIcon = child.icon;
return (
<Link
key={child.href}
to={child.href}
className={cn(
'flex items-center rounded-lg px-3 py-1.5 text-sm transition-colors',
isChildActive
? 'bg-indigo-700/50 text-white dark:bg-indigo-900/30 dark:text-indigo-300'
: 'text-indigo-300 hover:bg-indigo-800/30 hover:text-white dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-300'
)}
>
<ChildIcon className="h-4 w-4 flex-shrink-0 text-indigo-400 dark:text-gray-500" />
<span className="ml-2">{child.name}</span>
</Link>
);
})}
</div>
)}
</div>
);
})}
</nav>
{/* User menu */}
<div className="border-t border-indigo-700/50 p-4 dark:border-gray-700">
{(!sidebarCollapsed || isMobile) ? (
<div className="flex items-center">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-purple-500/30 text-purple-300 dark:bg-purple-900/30 dark:text-purple-400">
{user?.firstName?.[0]}{user?.lastName?.[0]}
</div>
<div className="ml-3 flex-1 overflow-hidden">
<p className="truncate text-sm font-medium text-white dark:text-white">
{user?.firstName} {user?.lastName}
</p>
<p className="truncate text-xs text-indigo-300 dark:text-gray-400">{user?.email}</p>
</div>
<button
onClick={logout}
className="p-2 text-indigo-400 hover:text-white dark:text-gray-500 dark:hover:text-gray-300"
title="Cerrar sesión"
aria-label="Cerrar sesión"
>
<LogOut className="h-4 w-4" />
</button>
</div>
) : (
<button
onClick={logout}
className="flex w-full items-center justify-center p-2 text-indigo-400 hover:text-white dark:text-gray-500 dark:hover:text-gray-300"
title="Cerrar sesión"
aria-label="Cerrar sesión"
>
<LogOut className="h-5 w-5" />
</button>
)}
</div>
</aside>
{/* Main content */}
<div
className={cn(
'transition-all duration-300',
isMobile ? 'ml-0' : sidebarCollapsed ? 'ml-16' : 'ml-64'
)}
>
{/* Top bar */}
<header className="sticky top-0 z-30 flex h-16 items-center justify-between border-b bg-white px-4 shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-center gap-4">
<button
onClick={toggleSidebar}
className="rounded-lg p-2 text-indigo-600 hover:bg-indigo-50 dark:text-indigo-400 dark:hover:bg-gray-700"
aria-label="Abrir menú"
>
<Menu className="h-5 w-5" />
</button>
<SearchButton />
</div>
<div className="flex items-center space-x-4">
{/* Superadmin badge indicator */}
<div className="hidden items-center gap-2 rounded-full bg-purple-100 px-3 py-1 text-sm font-medium text-purple-700 sm:flex dark:bg-purple-900/30 dark:text-purple-400">
<Shield className="h-4 w-4" />
<span>Superadmin</span>
</div>
<ThemeToggle />
<button
className="relative rounded-lg p-2 hover:bg-indigo-50 dark:hover:bg-gray-700"
aria-label="Notificaciones"
>
<Bell className="h-5 w-5 text-indigo-600 dark:text-indigo-400" />
<span className="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-purple-500 text-xs text-white">
5
</span>
</button>
<div className="flex items-center space-x-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-r from-indigo-500 to-purple-500 text-sm font-medium text-white">
{user?.firstName?.[0]}{user?.lastName?.[0]}
</div>
<ChevronDown className="h-4 w-4 text-indigo-500 dark:text-indigo-400" />
</div>
</div>
</header>
{/* Page content */}
<main className="p-6">{children}</main>
</div>
</div>
);
}
/**
* 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 (
* <SuperadminLayout>
* <h1>Superadmin Dashboard</h1>
* </SuperadminLayout>
* );
* }
* ```
*/
export function SuperadminLayout({ children }: SuperadminLayoutProps) {
return (
<CommandPaletteWithRouter>
<SuperadminLayoutInner>{children}</SuperadminLayoutInner>
</CommandPaletteWithRouter>
);
}

View File

@ -1,2 +1,3 @@
export * from './AuthLayout'; export * from './AuthLayout';
export * from './DashboardLayout'; export * from './DashboardLayout';
export * from './SuperadminLayout';

View File

@ -2,6 +2,7 @@ import { lazy, Suspense } from 'react';
import { createBrowserRouter, Navigate } from 'react-router-dom'; import { createBrowserRouter, Navigate } from 'react-router-dom';
import { ProtectedRoute } from './ProtectedRoute'; import { ProtectedRoute } from './ProtectedRoute';
import { DashboardLayout } from '@app/layouts/DashboardLayout'; import { DashboardLayout } from '@app/layouts/DashboardLayout';
import { SuperadminLayout } from '@app/layouts/SuperadminLayout';
import { FullPageSpinner } from '@components/atoms/Spinner'; import { FullPageSpinner } from '@components/atoms/Spinner';
// Lazy load pages // 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 WarehousesListPage = lazy(() => import('@pages/inventory/WarehousesListPage').then(m => ({ default: m.WarehousesListPage })));
const WarehouseDetailPage = lazy(() => import('@pages/inventory/WarehouseDetailPage').then(m => ({ default: m.WarehouseDetailPage }))); 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 }) { function LazyWrapper({ children }: { children: React.ReactNode }) {
return <Suspense fallback={<FullPageSpinner />}>{children}</Suspense>; return <Suspense fallback={<FullPageSpinner />}>{children}</Suspense>;
} }
@ -92,6 +100,16 @@ function DashboardWrapper({ children }: { children: React.ReactNode }) {
); );
} }
function SuperadminWrapper({ children }: { children: React.ReactNode }) {
return (
<ProtectedRoute requiredRoles={['superadmin']}>
<SuperadminLayout>
<LazyWrapper>{children}</LazyWrapper>
</SuperadminLayout>
</ProtectedRoute>
);
}
export const router = createBrowserRouter([ export const router = createBrowserRouter([
// Public routes // Public routes
{ {
@ -602,6 +620,84 @@ export const router = createBrowserRouter([
), ),
}, },
// Superadmin routes
{
path: '/superadmin',
element: <Navigate to="/superadmin/dashboard" replace />,
},
{
path: '/superadmin/dashboard',
element: (
<SuperadminWrapper>
<SuperadminDashboardPage />
</SuperadminWrapper>
),
},
{
path: '/superadmin/tenants',
element: (
<SuperadminWrapper>
<TenantsListPage />
</SuperadminWrapper>
),
},
{
path: '/superadmin/tenants/new',
element: (
<SuperadminWrapper>
<TenantCreatePage />
</SuperadminWrapper>
),
},
{
path: '/superadmin/tenants/:id',
element: (
<SuperadminWrapper>
<TenantDetailPage />
</SuperadminWrapper>
),
},
{
path: '/superadmin/tenants/:id/edit',
element: (
<SuperadminWrapper>
<TenantEditPage />
</SuperadminWrapper>
),
},
{
path: '/superadmin/billing/*',
element: (
<SuperadminWrapper>
<div className="text-center text-gray-500">Gestión de Facturación Global - En desarrollo</div>
</SuperadminWrapper>
),
},
{
path: '/superadmin/system/*',
element: (
<SuperadminWrapper>
<div className="text-center text-gray-500">Configuración del Sistema - En desarrollo</div>
</SuperadminWrapper>
),
},
{
path: '/superadmin/analytics',
element: (
<SuperadminWrapper>
<div className="text-center text-gray-500">Analytics Global - En desarrollo</div>
</SuperadminWrapper>
),
},
{
path: '/superadmin/*',
element: (
<SuperadminWrapper>
<div className="text-center text-gray-500">Portal Superadmin - En desarrollo</div>
</SuperadminWrapper>
),
},
// Error pages // Error pages
{ {
path: '/unauthorized', path: '/unauthorized',

View File

@ -0,0 +1 @@
export { tenantsApi, type TenantDashboardStats } from './tenants.api';

View File

@ -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<TenantsResponse> {
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<TenantsResponse>(url);
return response.data;
},
/**
* Get tenant by ID
*/
async getById(id: string): Promise<Tenant> {
const response = await api.get<Tenant>(`${BASE_URL}/${id}`);
return response.data;
},
/**
* Get tenant with stats
*/
async getWithStats(id: string): Promise<TenantWithStats> {
const response = await api.get<TenantWithStats>(`${BASE_URL}/${id}/with-stats`);
return response.data;
},
/**
* Get tenant statistics
*/
async getStats(id: string): Promise<TenantStats> {
const response = await api.get<TenantStats>(`${BASE_URL}/${id}/stats`);
return response.data;
},
/**
* Get dashboard statistics (counts by status)
*/
async getDashboardStats(): Promise<TenantDashboardStats> {
const response = await api.get<TenantDashboardStats>(`${BASE_URL}/dashboard-stats`);
return response.data;
},
/**
* Create new tenant
*/
async create(data: CreateTenantDto): Promise<Tenant> {
const response = await api.post<Tenant>(BASE_URL, data);
return response.data;
},
/**
* Update tenant
*/
async update(id: string, data: UpdateTenantDto): Promise<Tenant> {
const response = await api.patch<Tenant>(`${BASE_URL}/${id}`, data);
return response.data;
},
/**
* Suspend tenant
*/
async suspend(id: string, data: SuspendTenantDto): Promise<Tenant> {
const response = await api.post<Tenant>(`${BASE_URL}/${id}/suspend`, data);
return response.data;
},
/**
* Activate tenant
*/
async activate(id: string, data?: ActivateTenantDto): Promise<Tenant> {
const response = await api.post<Tenant>(`${BASE_URL}/${id}/activate`, data || {});
return response.data;
},
/**
* Delete tenant (soft delete)
*/
async delete(id: string): Promise<void> {
await api.delete(`${BASE_URL}/${id}`);
},
/**
* Permanently delete tenant (hard delete)
*/
async permanentDelete(id: string): Promise<void> {
await api.delete(`${BASE_URL}/${id}/permanent`);
},
};

View File

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

View File

@ -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<void>;
refreshOverview: () => Promise<void>;
refreshActivity: () => Promise<void>;
refreshSystemHealth: () => Promise<void>;
}
// ==================== Mock Data Generation ====================
const PLAN_COLORS: Record<TenantPlanTier, string> = {
free: '#9CA3AF',
starter: '#3B82F6',
professional: '#8B5CF6',
enterprise: '#F59E0B',
custom: '#10B981',
};
const PLAN_LABELS: Record<TenantPlanTier, string> = {
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<SuperadminOverviewStats | null>(null);
const [tenantGrowth, setTenantGrowth] = useState<TenantGrowthDataPoint[]>([]);
const [planDistribution, setPlanDistribution] = useState<PlanDistribution[]>([]);
const [revenueTrend, setRevenueTrend] = useState<RevenueDataPoint[]>([]);
const [recentActivity, setRecentActivity] = useState<RecentTenantActivity[]>([]);
const [topTenants, setTopTenants] = useState<TopTenant[]>([]);
const [systemHealth, setSystemHealth] = useState<SystemHealthMetrics | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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,
};
}

View File

@ -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<string, TenantStats> = {
'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<void>;
createTenant: (data: CreateTenantDto) => Promise<Tenant>;
updateTenant: (id: string, data: UpdateTenantDto) => Promise<Tenant>;
deleteTenant: (id: string) => Promise<void>;
suspendTenant: (id: string, data: SuspendTenantDto) => Promise<Tenant>;
activateTenant: (id: string, data?: ActivateTenantDto) => Promise<Tenant>;
changePlan: (id: string, data: ChangeTenantPlanDto) => Promise<Tenant>;
getTenantStats: (id: string) => Promise<TenantStats>;
}
// ==================== useTenants Hook ====================
export function useTenants(initialFilters?: TenantFilters): UseTenantsReturn {
const [state, setState] = useState<UseTenantsState>({
tenants: [],
total: 0,
page: 1,
totalPages: 1,
isLoading: true,
error: null,
});
const [filters, setFilters] = useState<TenantFilters>(
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<Tenant> => {
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<Tenant> => {
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<void> => {
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<Tenant> => {
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<Tenant> => {
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<Tenant> => {
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<string, Tenant['planTier']> = {
'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<TenantStats> => {
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<TenantWithStats | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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<TenantUsageHistory[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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<TenantSubscription | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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 };
}

View File

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

View File

@ -0,0 +1,6 @@
/**
* Superadmin Types - Index
* Exports all types for the superadmin portal
*/
export * from './tenant';

View File

@ -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<string, boolean>;
// 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<string, boolean>;
}
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<string, boolean>;
}
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<string, unknown>;
performedBy?: string;
performedByName?: string;
createdAt: string;
}

View File

@ -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 <UserPlus className={`${iconClass} text-success-500`} />;
case 'upgrade':
return <ArrowUp className={`${iconClass} text-primary-500`} />;
case 'downgrade':
return <ArrowDown className={`${iconClass} text-warning-500`} />;
case 'suspension':
return <AlertTriangle className={`${iconClass} text-danger-500`} />;
case 'payment':
return <DollarSign className={`${iconClass} text-success-500`} />;
default:
return <Activity className={`${iconClass} text-gray-500`} />;
}
}
function getTrendIcon(trend: 'up' | 'down' | 'stable'): React.ReactNode {
switch (trend) {
case 'up':
return <TrendingUp className="h-4 w-4 text-danger-500" />;
case 'down':
return <TrendingDown className="h-4 w-4 text-success-500" />;
default:
return <Activity className="h-4 w-4 text-gray-500" />;
}
}
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 (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-gray-500">{title}</p>
<p className="mt-1 text-2xl font-semibold text-gray-900">{formattedValue}</p>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary-50">
{icon}
</div>
</div>
{change !== undefined && (
<div className="mt-4 flex items-center">
{change > 0 ? (
<TrendingUp className="h-4 w-4 text-success-500" />
) : change < 0 ? (
<TrendingDown className="h-4 w-4 text-danger-500" />
) : null}
<span className={`ml-1 text-sm font-medium ${getChangeColor(change)}`}>
{change > 0 ? '+' : ''}{change}%
</span>
<span className="ml-2 text-sm text-gray-500">{changeLabel}</span>
</div>
)}
</CardContent>
</Card>
);
}
interface WidgetContainerProps {
title: string;
children: React.ReactNode;
action?: React.ReactNode;
className?: string;
}
function WidgetContainer({ title, children, action, className = '' }: WidgetContainerProps) {
return (
<Card className={className}>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{title}</CardTitle>
{action}
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
// 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 (
<div className="h-64">
<div className="flex h-full items-end gap-1">
{data.map((point) => {
const height = ((point.totalTenants - min) / range) * 100;
const normalizedHeight = Math.max(height, 10);
return (
<div key={point.month} className="flex flex-1 flex-col items-center">
<div
className="w-full rounded-t bg-primary-500 transition-all hover:bg-primary-600"
style={{ height: `${normalizedHeight}%` }}
title={`${point.month}: ${point.totalTenants} tenants`}
/>
<span className="mt-2 text-xs text-gray-500">{point.month}</span>
</div>
);
})}
</div>
<div className="mt-4 flex items-center justify-center gap-4 text-xs text-gray-500">
<div className="flex items-center gap-1">
<div className="h-3 w-3 rounded bg-primary-500" />
<span>Total Tenants</span>
</div>
</div>
</div>
);
}
interface PieChartPlaceholderProps {
data: PlanDistribution[];
}
function PieChartPlaceholder({ data }: PieChartPlaceholderProps) {
const total = data.reduce((acc, item) => acc + item.count, 0);
return (
<div className="h-64">
<div className="flex h-full flex-col justify-center">
{/* Visual representation */}
<div className="mb-4 flex justify-center">
<div className="relative h-32 w-32">
<svg className="h-full w-full" viewBox="0 0 100 100">
{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 = (
<path
key={item.plan}
d={`M 50 50 L ${x1} ${y1} A 40 40 0 ${largeArc} 1 ${x2} ${y2} Z`}
fill={item.color}
className="transition-opacity hover:opacity-80"
>
<title>{`${item.planLabel}: ${item.count} (${item.percentage}%)`}</title>
</path>
);
acc.paths.push(path);
acc.currentAngle = endAngle;
return acc;
},
{ paths: [] as React.ReactNode[], currentAngle: 0 }
).paths}
</svg>
</div>
</div>
{/* Legend */}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{data.map((item) => (
<div key={item.plan} className="flex items-center gap-2 text-xs">
<div className="h-3 w-3 rounded-full" style={{ backgroundColor: item.color }} />
<span className="text-gray-600">{item.planLabel}</span>
<span className="font-medium text-gray-900">{item.percentage}%</span>
</div>
))}
</div>
</div>
</div>
);
}
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 (
<div className="h-64">
<div className="relative flex h-full items-end gap-1">
{/* Area gradient background */}
<div className="absolute inset-0 flex items-end">
<svg className="h-full w-full" preserveAspectRatio="none">
<defs>
<linearGradient id="areaGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="rgb(34, 197, 94)" stopOpacity="0.3" />
<stop offset="100%" stopColor="rgb(34, 197, 94)" stopOpacity="0.05" />
</linearGradient>
</defs>
<path
d={`M 0 ${100 - ((data[0]?.revenue || 0 - min) / range) * 100} ${data
.map((point, i) => {
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)"
/>
</svg>
</div>
{/* Bars */}
{data.map((point) => {
const height = ((point.revenue - min) / range) * 100;
return (
<div key={point.month} className="relative z-10 flex flex-1 flex-col items-center">
<div
className="w-full rounded-t bg-success-500/60 transition-all hover:bg-success-500"
style={{ height: `${Math.max(height, 5)}%` }}
title={`${point.month}: ${formatCurrency(point.revenue)}`}
/>
<span className="mt-2 text-xs text-gray-500">{point.month}</span>
</div>
);
})}
</div>
</div>
);
}
interface RecentActivityFeedProps {
activities: RecentTenantActivity[];
}
function RecentActivityFeed({ activities }: RecentActivityFeedProps) {
if (activities.length === 0) {
return (
<div className="flex h-64 items-center justify-center text-gray-500">
No hay actividad reciente
</div>
);
}
return (
<div className="h-80 space-y-3 overflow-y-auto">
{activities.map((activity) => (
<div
key={activity.id}
className="flex items-start gap-3 rounded-lg border border-gray-100 bg-gray-50 p-3 transition-colors hover:bg-gray-100"
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-white shadow-sm">
{getActivityIcon(activity.icon)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-gray-900">{activity.tenantName}</p>
<p className="text-sm text-gray-600">{activity.description}</p>
<p className="mt-1 flex items-center gap-1 text-xs text-gray-400">
<Clock className="h-3 w-3" />
{formatRelativeTime(activity.timestamp)}
</p>
</div>
</div>
))}
</div>
);
}
interface TopTenantsTableProps {
tenants: TopTenant[];
}
function TopTenantsTable({ tenants }: TopTenantsTableProps) {
if (tenants.length === 0) {
return (
<div className="flex h-40 items-center justify-center text-gray-500">
No hay datos de tenants
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Tenant
</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Plan
</th>
<th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
Usuarios
</th>
<th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
MRR
</th>
<th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
Storage
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{tenants.map((tenant, index) => (
<tr key={tenant.id} className="hover:bg-gray-50">
<td className="whitespace-nowrap px-4 py-3">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary-100 text-sm font-medium text-primary-700">
{index + 1}
</div>
<div>
<p className="text-sm font-medium text-gray-900">{tenant.name}</p>
<p className="text-xs text-gray-500">{tenant.slug}</p>
</div>
</div>
</td>
<td className="whitespace-nowrap px-4 py-3">
<Badge variant={getPlanBadgeVariant(tenant.plan)} size="sm">
{tenant.plan.charAt(0).toUpperCase() + tenant.plan.slice(1)}
</Badge>
</td>
<td className="whitespace-nowrap px-4 py-3 text-right text-sm text-gray-900">
{formatNumber(tenant.usersCount)}
</td>
<td className="whitespace-nowrap px-4 py-3 text-right text-sm font-medium text-gray-900">
{formatCurrency(tenant.monthlyRevenue)}
</td>
<td className="whitespace-nowrap px-4 py-3 text-right text-sm text-gray-600">
{tenant.storageUsedGb.toFixed(1)} GB
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
interface SystemHealthCardProps {
health: SystemHealthMetrics | null;
onRefresh: () => void;
}
function SystemHealthCard({ health, onRefresh }: SystemHealthCardProps) {
if (!health) {
return (
<Card>
<CardContent className="flex h-48 items-center justify-center">
<Spinner size="md" />
</CardContent>
</Card>
);
}
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 (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-base">Estado del Sistema</CardTitle>
<Button variant="ghost" size="sm" onClick={onRefresh}>
<RefreshCw className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent className="space-y-4">
{/* Overall Status */}
<div className={`flex items-center justify-between rounded-lg p-3 ${statusInfo.bg}`}>
<div className="flex items-center gap-2">
{statusInfo.status === 'healthy' ? (
<CheckCircle className={`h-5 w-5 ${statusInfo.color}`} />
) : (
<AlertTriangle className={`h-5 w-5 ${statusInfo.color}`} />
)}
<span className={`font-medium ${statusInfo.color}`}>{statusInfo.label}</span>
</div>
<span className="text-sm text-gray-500">
Uptime: {health.uptimePercent}%
</span>
</div>
{/* Metrics Grid */}
<div className="grid grid-cols-2 gap-4">
{/* API Response Time */}
<div className="rounded-lg border border-gray-200 p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-primary-500" />
<span className="text-xs text-gray-500">Resp. Time</span>
</div>
{getTrendIcon(health.apiResponseTimeTrend)}
</div>
<p className="mt-1 text-lg font-semibold text-gray-900">{health.apiResponseTimeMs}ms</p>
</div>
{/* Error Rate */}
<div className="rounded-lg border border-gray-200 p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-warning-500" />
<span className="text-xs text-gray-500">Error Rate</span>
</div>
{getTrendIcon(health.errorRateTrend)}
</div>
<p className="mt-1 text-lg font-semibold text-gray-900">{health.errorRate}%</p>
</div>
{/* Active Connections */}
<div className="rounded-lg border border-gray-200 p-3">
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-blue-500" />
<span className="text-xs text-gray-500">Conexiones</span>
</div>
<p className="mt-1 text-lg font-semibold text-gray-900">
{formatNumber(health.activeConnections)}
</p>
</div>
{/* Queued Jobs */}
<div className="rounded-lg border border-gray-200 p-3">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-gray-500" />
<span className="text-xs text-gray-500">Jobs en Cola</span>
</div>
<p className="mt-1 text-lg font-semibold text-gray-900">{health.queuedJobs}</p>
</div>
</div>
</CardContent>
</Card>
);
}
// ==================== 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 (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<Spinner size="lg" />
<p className="mt-4 text-gray-500">Cargando dashboard...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<AlertTriangle className="mx-auto h-12 w-12 text-danger-500" />
<h2 className="mt-4 text-lg font-semibold text-gray-900">Error al cargar datos</h2>
<p className="mt-2 text-gray-500">{error}</p>
<Button onClick={refresh} className="mt-4">
Reintentar
</Button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 p-6">
<div className="mx-auto max-w-7xl space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Panel de Superadmin</h1>
<p className="mt-1 text-sm text-gray-500 capitalize">{currentDate}</p>
</div>
<Button onClick={refresh} variant="outline">
<RefreshCw className="mr-2 h-4 w-4" />
Actualizar
</Button>
</div>
{/* Key Metrics Row */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Total Tenants"
value={overview?.totalTenants || 0}
change={overview?.tenantsChange}
icon={<Building2 className="h-6 w-6 text-primary-600" />}
/>
<StatCard
title="Suscripciones Activas"
value={overview?.activeSubscriptions || 0}
change={overview?.subscriptionsChange}
icon={<CreditCard className="h-6 w-6 text-primary-600" />}
/>
<StatCard
title="MRR (Ingresos Recurrentes)"
value={overview?.monthlyRecurringRevenue || 0}
change={overview?.mrrChange}
icon={<DollarSign className="h-6 w-6 text-primary-600" />}
format="currency"
/>
<StatCard
title="Usuarios Totales"
value={overview?.totalUsersAcrossTenants || 0}
change={overview?.usersChange}
icon={<Users className="h-6 w-6 text-primary-600" />}
/>
</div>
{/* Charts Row */}
<div className="grid gap-6 lg:grid-cols-3">
{/* Tenant Growth Chart */}
<WidgetContainer
title="Crecimiento de Tenants"
className="lg:col-span-1"
action={
<span className="text-xs text-gray-500">Ultimos 12 meses</span>
}
>
<LineChartPlaceholder data={tenantGrowth} />
</WidgetContainer>
{/* Plan Distribution Chart */}
<WidgetContainer
title="Distribucion por Plan"
className="lg:col-span-1"
>
<PieChartPlaceholder data={planDistribution} />
</WidgetContainer>
{/* Revenue Trend Chart */}
<WidgetContainer
title="Tendencia de Ingresos"
className="lg:col-span-1"
action={
<span className="text-xs text-gray-500">Ultimos 12 meses</span>
}
>
<AreaChartPlaceholder data={revenueTrend} />
</WidgetContainer>
</div>
{/* Activity & Top Tenants Row */}
<div className="grid gap-6 lg:grid-cols-3">
{/* Recent Activity Feed */}
<WidgetContainer
title="Actividad Reciente"
className="lg:col-span-1"
action={
<a
href="/superadmin/activity"
className="flex items-center gap-1 text-xs text-primary-600 hover:text-primary-700"
>
Ver todo
<ArrowUpRight className="h-3 w-3" />
</a>
}
>
<RecentActivityFeed activities={recentActivity} />
</WidgetContainer>
{/* Top Tenants Table */}
<WidgetContainer
title="Top Tenants por Ingresos"
className="lg:col-span-2"
action={
<a
href="/superadmin/tenants"
className="flex items-center gap-1 text-xs text-primary-600 hover:text-primary-700"
>
Ver todos
<ArrowUpRight className="h-3 w-3" />
</a>
}
>
<TopTenantsTable tenants={topTenants} />
</WidgetContainer>
</div>
{/* System Health & Quick Stats Row */}
<div className="grid gap-6 lg:grid-cols-3">
{/* System Health */}
<div className="lg:col-span-1">
<SystemHealthCard health={systemHealth} onRefresh={refreshSystemHealth} />
</div>
{/* Additional Stats */}
<div className="grid gap-4 sm:grid-cols-2 lg:col-span-2">
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-warning-100">
<Clock className="h-6 w-6 text-warning-600" />
</div>
<div>
<p className="text-sm text-gray-500">En Periodo de Prueba</p>
<p className="text-2xl font-semibold text-gray-900">
{overview?.trialTenants || 0}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-danger-100">
<AlertTriangle className="h-6 w-6 text-danger-600" />
</div>
<div>
<p className="text-sm text-gray-500">Tenants Suspendidos</p>
<p className="text-2xl font-semibold text-gray-900">
{overview?.suspendedTenants || 0}
</p>
</div>
</div>
</CardContent>
</Card>
<Card className="sm:col-span-2">
<CardContent className="p-6">
<h3 className="mb-4 text-sm font-medium text-gray-500">Acciones Rapidas</h3>
<div className="grid gap-3 sm:grid-cols-3">
<a
href="/superadmin/tenants/create"
className="flex items-center gap-2 rounded-lg border border-gray-200 p-3 transition-colors hover:border-primary-300 hover:bg-primary-50"
>
<Building2 className="h-5 w-5 text-primary-600" />
<span className="text-sm font-medium text-gray-700">Nuevo Tenant</span>
</a>
<a
href="/superadmin/subscriptions"
className="flex items-center gap-2 rounded-lg border border-gray-200 p-3 transition-colors hover:border-primary-300 hover:bg-primary-50"
>
<CreditCard className="h-5 w-5 text-primary-600" />
<span className="text-sm font-medium text-gray-700">Suscripciones</span>
</a>
<a
href="/superadmin/reports"
className="flex items-center gap-2 rounded-lg border border-gray-200 p-3 transition-colors hover:border-primary-300 hover:bg-primary-50"
>
<Activity className="h-5 w-5 text-primary-600" />
<span className="text-sm font-medium text-gray-700">Reportes</span>
</a>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
);
}
export default SuperadminDashboardPage;

View File

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

View File

@ -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<string | null>(null);
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors },
} = useForm<FormData>({
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<HTMLInputElement>) => {
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<TenantPlanTier, { users: number; branches: number; storage: number }> = {
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 (
<div className="space-y-6 p-6">
{/* Breadcrumbs */}
<Breadcrumbs
items={[
{ label: 'Superadmin', href: '/superadmin' },
{ label: 'Tenants', href: '/superadmin/tenants' },
{ label: 'Nuevo tenant' },
]}
/>
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => navigate('/superadmin/tenants')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Volver
</Button>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-100">
<Building2 className="h-6 w-6 text-primary-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">Nuevo tenant</h1>
<p className="text-sm text-gray-500">
Crea una nueva organizacion en la plataforma
</p>
</div>
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-6">
<div className="grid gap-6 lg:grid-cols-3">
{/* Main form */}
<div className="lg:col-span-2 space-y-6">
{error && (
<Alert
variant="danger"
title="Error"
onClose={() => setError(null)}
>
{error}
</Alert>
)}
{/* Basic Information */}
<Card>
<CardHeader>
<CardTitle>Informacion basica</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<FormField
label="Nombre del tenant"
error={errors.name?.message}
required
>
<Input
{...register('name', { required: 'El nombre es requerido' })}
placeholder="Mi Empresa S.A."
onChange={(e) => {
register('name').onChange(e);
handleNameChange(e);
}}
/>
</FormField>
<FormField
label="Slug (URL)"
error={errors.slug?.message}
required
>
<Input
{...register('slug', {
required: 'El slug es requerido',
pattern: {
value: /^[a-z0-9-]+$/,
message: 'Solo letras minusculas, numeros y guiones',
},
})}
placeholder="mi-empresa"
/>
</FormField>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<FormField label="Dominio personalizado (opcional)">
<Input
{...register('domain')}
placeholder="app.miempresa.com"
/>
</FormField>
<FormField label="Subdominio">
<Input
{...register('subdomain')}
placeholder="miempresa"
/>
</FormField>
</div>
</CardContent>
</Card>
{/* Owner Information */}
<Card>
<CardHeader>
<CardTitle>Propietario</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField
label="Nombre completo"
error={errors.ownerName?.message}
required
>
<Input
{...register('ownerName', { required: 'El nombre es requerido' })}
placeholder="Juan Perez"
/>
</FormField>
<div className="grid gap-4 sm:grid-cols-2">
<FormField
label="Email"
error={errors.ownerEmail?.message}
required
>
<Input
type="email"
{...register('ownerEmail', {
required: 'El email es requerido',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Email invalido',
},
})}
placeholder="admin@empresa.com"
/>
</FormField>
<FormField label="Telefono">
<Input
{...register('phone')}
placeholder="+52 55 1234 5678"
/>
</FormField>
</div>
<FormField label="Contrasena inicial (opcional)">
<Input
type="password"
{...register('ownerPassword')}
placeholder="Se generara automaticamente si se deja vacio"
/>
</FormField>
</CardContent>
</Card>
{/* Plan & Billing */}
<Card>
<CardHeader>
<CardTitle>Plan y Facturacion</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<FormField label="Plan" required>
<Select
options={PLAN_OPTIONS}
value={watch('planTier')}
onChange={(value) => {
setValue('planTier', value as TenantPlanTier);
updatePlanDefaults(value as TenantPlanTier);
}}
/>
</FormField>
<FormField label="Ciclo de facturacion">
<Select
options={BILLING_OPTIONS}
value={watch('billingCycle')}
onChange={(value) => setValue('billingCycle', value as BillingCycle)}
/>
</FormField>
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div>
<Label htmlFor="startTrial">Iniciar con periodo de prueba</Label>
<p className="text-sm text-gray-500">El tenant tendra acceso completo durante el periodo de prueba</p>
</div>
<Switch
id="startTrial"
checked={watch('startTrial')}
onChange={(checked) => setValue('startTrial', checked)}
/>
</div>
{startTrial && (
<FormField label="Dias de prueba">
<Input
type="number"
{...register('trialDays', { valueAsNumber: true, min: 1, max: 90 })}
min={1}
max={90}
/>
</FormField>
)}
</CardContent>
</Card>
{/* Limits */}
<Card>
<CardHeader>
<CardTitle>Limites</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-3">
<FormField label="Max. usuarios">
<Input
type="number"
{...register('maxUsers', { valueAsNumber: true, min: 1 })}
min={1}
/>
</FormField>
<FormField label="Max. sucursales">
<Input
type="number"
{...register('maxBranches', { valueAsNumber: true, min: 1 })}
min={1}
/>
</FormField>
<FormField label="Almacenamiento (GB)">
<Input
type="number"
{...register('maxStorageGb', { valueAsNumber: true, min: 1 })}
min={1}
/>
</FormField>
</div>
</CardContent>
</Card>
{/* Regional Settings */}
<Card>
<CardHeader>
<CardTitle>Configuracion regional</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-3">
<FormField label="Zona horaria">
<Select
options={TIMEZONE_OPTIONS}
value={watch('timezone')}
onChange={(value) => setValue('timezone', value as string)}
/>
</FormField>
<FormField label="Idioma">
<Select
options={LOCALE_OPTIONS}
value={watch('locale')}
onChange={(value) => setValue('locale', value as string)}
/>
</FormField>
<FormField label="Moneda">
<Select
options={CURRENCY_OPTIONS}
value={watch('currency')}
onChange={(value) => setValue('currency', value as string)}
/>
</FormField>
</div>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Features */}
<Card>
<CardHeader>
<CardTitle>Caracteristicas</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="advancedReporting">Reportes avanzados</Label>
<Switch
id="advancedReporting"
checked={watch('advancedReporting')}
onChange={(checked) => setValue('advancedReporting', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="apiAccess">Acceso API</Label>
<Switch
id="apiAccess"
checked={watch('apiAccess')}
onChange={(checked) => setValue('apiAccess', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="customBranding">Marca personalizada</Label>
<Switch
id="customBranding"
checked={watch('customBranding')}
onChange={(checked) => setValue('customBranding', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="sso">SSO</Label>
<Switch
id="sso"
checked={watch('sso')}
onChange={(checked) => setValue('sso', checked)}
/>
</div>
</CardContent>
</Card>
{/* Modules */}
<Card>
<CardHeader>
<CardTitle>Modulos</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="moduleCrm">CRM</Label>
<Switch
id="moduleCrm"
checked={watch('moduleCrm')}
onChange={(checked) => setValue('moduleCrm', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="moduleInventory">Inventario</Label>
<Switch
id="moduleInventory"
checked={watch('moduleInventory')}
onChange={(checked) => setValue('moduleInventory', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="moduleSales">Ventas</Label>
<Switch
id="moduleSales"
checked={watch('moduleSales')}
onChange={(checked) => setValue('moduleSales', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="modulePurchases">Compras</Label>
<Switch
id="modulePurchases"
checked={watch('modulePurchases')}
onChange={(checked) => setValue('modulePurchases', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="moduleHr">Recursos Humanos</Label>
<Switch
id="moduleHr"
checked={watch('moduleHr')}
onChange={(checked) => setValue('moduleHr', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="moduleAccounting">Contabilidad</Label>
<Switch
id="moduleAccounting"
checked={watch('moduleAccounting')}
onChange={(checked) => setValue('moduleAccounting', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="moduleProjects">Proyectos</Label>
<Switch
id="moduleProjects"
checked={watch('moduleProjects')}
onChange={(checked) => setValue('moduleProjects', checked)}
/>
</div>
</CardContent>
</Card>
{/* Info card */}
<Card className="bg-blue-50 border-blue-200">
<CardContent className="pt-6">
<h3 className="font-medium text-blue-900 mb-3">Al crear un tenant:</h3>
<ul className="space-y-2 text-sm text-blue-800">
<li className="flex items-start gap-2">
<span className="mt-1 h-1.5 w-1.5 rounded-full bg-blue-500 flex-shrink-0" />
Se creara una base de datos aislada
</li>
<li className="flex items-start gap-2">
<span className="mt-1 h-1.5 w-1.5 rounded-full bg-blue-500 flex-shrink-0" />
Se enviara un email de invitacion
</li>
<li className="flex items-start gap-2">
<span className="mt-1 h-1.5 w-1.5 rounded-full bg-blue-500 flex-shrink-0" />
El propietario configurara su organizacion
</li>
</ul>
</CardContent>
</Card>
{/* Actions */}
<div className="flex flex-col gap-3">
<Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting ? 'Creando...' : 'Crear tenant'}
</Button>
<Button
type="button"
variant="outline"
onClick={() => navigate('/superadmin/tenants')}
className="w-full"
>
Cancelar
</Button>
</div>
</div>
</div>
</form>
</div>
);
}
export default TenantCreatePage;

View File

@ -0,0 +1,850 @@
/**
* TenantDetailPage - Superadmin Portal
* Detailed view of a single tenant with stats, users, usage, and settings
*/
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
ArrowLeft,
Edit,
Trash2,
Pause,
Play,
Users,
HardDrive,
Activity,
Building2,
Calendar,
CreditCard,
Settings,
BarChart3,
Clock,
Globe,
Shield,
Zap,
TrendingUp,
TrendingDown,
CheckCircle,
AlertTriangle,
XCircle,
} from 'lucide-react';
import { Button } from '@components/atoms/Button';
import { Badge } from '@components/atoms/Badge';
import { Spinner } from '@components/atoms/Spinner';
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
import { ConfirmModal } from '@components/organisms/Modal';
import { Tabs, TabList, Tab, TabPanels, TabPanel } from '@components/organisms/Tabs';
import { useToast } from '@components/organisms/Toast';
import { ErrorEmptyState } from '@components/templates/EmptyState';
import { StatCard } from '@components/organisms/DashboardWidgets/StatCard';
import { PerformanceChart } from '@components/organisms/DashboardWidgets/PerformanceChart';
import {
useTenant,
useTenants,
useTenantUsageHistory,
useTenantSubscription,
} from '@features/superadmin';
import type {
TenantStatus,
SuspendTenantDto,
} from '@features/superadmin';
import { formatDate, formatCurrency } from '@utils/formatters';
import { cn } from '@utils/cn';
// ==================== Status Badge ====================
const STATUS_CONFIG: Record<
TenantStatus,
{ label: string; variant: 'success' | 'info' | 'danger' | 'default' | 'warning'; icon: typeof CheckCircle }
> = {
active: { label: 'Activo', variant: 'success', icon: CheckCircle },
trial: { label: 'Prueba', variant: 'info', icon: Clock },
suspended: { label: 'Suspendido', variant: 'danger', icon: Pause },
cancelled: { label: 'Cancelado', variant: 'default', icon: XCircle },
pending: { label: 'Pendiente', variant: 'warning', icon: AlertTriangle },
};
function TenantStatusBadge({ status }: { status: TenantStatus }) {
const config = STATUS_CONFIG[status] || STATUS_CONFIG.pending;
const Icon = config.icon;
return (
<Badge variant={config.variant} className="inline-flex items-center gap-1">
<Icon className="h-3 w-3" />
{config.label}
</Badge>
);
}
// ==================== Main Component ====================
export function TenantDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { showToast } = useToast();
// Hooks
const { tenant, isLoading, error, refresh } = useTenant(id);
const { history: usageHistory, isLoading: historyLoading } = useTenantUsageHistory(id, 30);
const { subscription, isLoading: subscriptionLoading } = useTenantSubscription(id);
const { deleteTenant, suspendTenant, activateTenant } = useTenants();
// State
const [activeTab, setActiveTab] = useState('overview');
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showSuspendModal, setShowSuspendModal] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
// ==================== Handlers ====================
const handleDelete = async () => {
if (!id) return;
setIsProcessing(true);
try {
await deleteTenant(id);
showToast({
type: 'success',
title: 'Tenant eliminado',
message: 'El tenant ha sido eliminado exitosamente.',
});
navigate('/superadmin/tenants');
} catch {
showToast({
type: 'error',
title: 'Error',
message: 'No se pudo eliminar el tenant.',
});
} finally {
setIsProcessing(false);
setShowDeleteModal(false);
}
};
const handleSuspend = async () => {
if (!id) return;
setIsProcessing(true);
try {
const suspendData: SuspendTenantDto = {
reason: 'Suspension manual por superadmin',
suspendImmediately: true,
notifyOwner: true,
};
await suspendTenant(id, suspendData);
showToast({
type: 'success',
title: 'Tenant suspendido',
message: 'El tenant ha sido suspendido exitosamente.',
});
refresh();
} catch {
showToast({
type: 'error',
title: 'Error',
message: 'No se pudo suspender el tenant.',
});
} finally {
setIsProcessing(false);
setShowSuspendModal(false);
}
};
const handleActivate = async () => {
if (!id) return;
setIsProcessing(true);
try {
await activateTenant(id, { notifyOwner: true });
showToast({
type: 'success',
title: 'Tenant activado',
message: 'El tenant ha sido activado exitosamente.',
});
refresh();
} catch {
showToast({
type: 'error',
title: 'Error',
message: 'No se pudo activar el tenant.',
});
} finally {
setIsProcessing(false);
}
};
// ==================== Loading / Error States ====================
if (isLoading) {
return (
<div className="flex h-96 items-center justify-center">
<Spinner size="lg" />
</div>
);
}
if (error || !tenant) {
return (
<div className="p-6">
<ErrorEmptyState
title="Tenant no encontrado"
description="No se pudo cargar la informacion del tenant."
onRetry={refresh}
/>
</div>
);
}
// ==================== Derived Data ====================
const stats = tenant.stats;
// Usage chart data
const usageChartData = usageHistory.map((h) => ({
name: formatDate(h.date, 'short'),
value: h.apiCalls,
storage: h.storageGb,
users: h.activeUsers,
}));
// Calculate usage percentages
const usagePercent = {
users: tenant.maxUsers > 0 ? Math.round((stats.totalUsers / tenant.maxUsers) * 100) : 0,
storage: tenant.maxStorageGb > 0 ? Math.round((stats.storageUsedGb / tenant.maxStorageGb) * 100) : 0,
apiCalls: tenant.maxApiCallsMonthly > 0 ? Math.round((stats.apiCallsThisMonth / tenant.maxApiCallsMonthly) * 100) : 0,
};
// ==================== Render ====================
return (
<div className="space-y-6 p-6">
{/* Breadcrumbs */}
<Breadcrumbs
items={[
{ label: 'Superadmin', href: '/superadmin' },
{ label: 'Tenants', href: '/superadmin/tenants' },
{ label: tenant.name },
]}
/>
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => navigate('/superadmin/tenants')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Volver
</Button>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-100">
<Building2 className="h-6 w-6 text-primary-600" />
</div>
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-gray-900">{tenant.name}</h1>
<TenantStatusBadge status={tenant.status} />
</div>
<p className="text-sm text-gray-500">{tenant.domain || tenant.subdomain || tenant.slug}</p>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" onClick={() => navigate(`/superadmin/tenants/${id}/edit`)}>
<Edit className="mr-2 h-4 w-4" />
Editar
</Button>
{(tenant.status === 'active' || tenant.status === 'trial') && (
<Button variant="secondary" onClick={() => setShowSuspendModal(true)}>
<Pause className="mr-2 h-4 w-4" />
Suspender
</Button>
)}
{tenant.status === 'suspended' && (
<Button variant="primary" onClick={handleActivate} disabled={isProcessing}>
<Play className="mr-2 h-4 w-4" />
Reactivar
</Button>
)}
<Button variant="danger" onClick={() => setShowDeleteModal(true)}>
<Trash2 className="mr-2 h-4 w-4" />
Eliminar
</Button>
</div>
</div>
{/* Main content with activity timeline */}
<div className="grid gap-6 lg:grid-cols-4">
{/* Main content */}
<div className="lg:col-span-3">
<Tabs value={activeTab} onChange={setActiveTab}>
<TabList>
<Tab value="overview" icon={<BarChart3 className="h-4 w-4" />}>
Resumen
</Tab>
<Tab value="subscription" icon={<CreditCard className="h-4 w-4" />}>
Suscripcion
</Tab>
<Tab value="usage" icon={<Activity className="h-4 w-4" />}>
Uso
</Tab>
<Tab value="settings" icon={<Settings className="h-4 w-4" />}>
Configuracion
</Tab>
</TabList>
<TabPanels>
{/* Overview Tab */}
<TabPanel value="overview">
<div className="space-y-6">
{/* Key metrics */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Usuarios"
value={stats.totalUsers}
icon={Users}
change={usagePercent.users}
changeType={usagePercent.users > 80 ? 'decrease' : 'neutral'}
changeLabel={`de ${tenant.maxUsers} max`}
/>
<StatCard
title="Almacenamiento"
value={`${stats.storageUsedGb.toFixed(1)} GB`}
icon={HardDrive}
change={usagePercent.storage}
changeType={usagePercent.storage > 80 ? 'decrease' : 'neutral'}
changeLabel={`de ${tenant.maxStorageGb} GB`}
/>
<StatCard
title="Llamadas API"
value={stats.apiCallsThisMonth.toLocaleString()}
icon={Activity}
change={usagePercent.apiCalls}
changeType={usagePercent.apiCalls > 80 ? 'decrease' : 'neutral'}
changeLabel="este mes"
/>
<StatCard
title="Sucursales"
value={stats.totalBranches}
icon={Building2}
changeLabel={`de ${tenant.maxBranches} max`}
/>
</div>
{/* Info cards */}
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Informacion general
</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-3">
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Propietario</dt>
<dd className="text-sm font-medium text-gray-900">{tenant.ownerName}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Email</dt>
<dd className="text-sm font-medium text-gray-900">{tenant.ownerEmail}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Creado</dt>
<dd className="text-sm font-medium text-gray-900">{formatDate(tenant.createdAt, 'full')}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Ultima actividad</dt>
<dd className="text-sm font-medium text-gray-900">
{tenant.lastActiveAt ? formatDate(tenant.lastActiveAt, 'relative') : 'N/A'}
</dd>
</div>
</dl>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CreditCard className="h-5 w-5" />
Plan actual
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="text-xl font-semibold text-gray-900">
{tenant.planName || tenant.planTier}
</p>
<p className="text-sm text-gray-500">
Ciclo: {tenant.billingCycle === 'annual' ? 'Anual' : 'Mensual'}
</p>
</div>
<Badge variant={tenant.status === 'active' ? 'success' : 'warning'}>
{tenant.status === 'active' ? 'Activo' :
tenant.status === 'trial' ? 'Prueba' :
tenant.status === 'suspended' ? 'Suspendido' : tenant.status}
</Badge>
</div>
{tenant.trialEndsAt && (
<p className="mt-3 text-sm text-warning-600">
Prueba termina: {formatDate(tenant.trialEndsAt, 'full')}
</p>
)}
</CardContent>
</Card>
</div>
{/* Usage chart */}
<Card>
<CardHeader>
<CardTitle>Uso de API (ultimos 30 dias)</CardTitle>
</CardHeader>
<CardContent>
<PerformanceChart
data={usageChartData}
type="area"
height={250}
loading={historyLoading}
/>
</CardContent>
</Card>
</div>
</TabPanel>
{/* Subscription Tab */}
<TabPanel value="subscription">
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Detalles de suscripcion</CardTitle>
</CardHeader>
<CardContent>
{subscriptionLoading ? (
<div className="flex justify-center py-8">
<Spinner />
</div>
) : subscription ? (
<dl className="grid gap-4 sm:grid-cols-2">
<div>
<dt className="text-sm text-gray-500">Plan</dt>
<dd className="text-lg font-semibold text-gray-900">{subscription.planName}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Precio</dt>
<dd className="text-lg font-semibold text-gray-900">
{formatCurrency(subscription.currentPrice, subscription.currency)}/
{subscription.billingCycle === 'annual' ? 'ano' : 'mes'}
</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Inicio del periodo</dt>
<dd className="font-medium text-gray-900">{formatDate(subscription.currentPeriodStart, 'full')}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Fin del periodo</dt>
<dd className="font-medium text-gray-900">{formatDate(subscription.currentPeriodEnd, 'full')}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Estado</dt>
<dd>
<Badge variant={subscription.status === 'active' ? 'success' : 'warning'}>
{subscription.status === 'active' ? 'Activo' :
subscription.status === 'trialing' ? 'Prueba' :
subscription.status === 'past_due' ? 'Pago pendiente' : subscription.status}
</Badge>
</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Renovacion automatica</dt>
<dd className="font-medium text-gray-900">
{subscription.autoRenew ? 'Si' : 'No'}
</dd>
</div>
{subscription.discountPercent > 0 && (
<div className="col-span-2">
<dt className="text-sm text-gray-500">Descuento</dt>
<dd className="font-medium text-success-600">
{subscription.discountPercent}% - {subscription.discountReason}
</dd>
</div>
)}
</dl>
) : (
<p className="text-gray-500">Sin suscripcion activa</p>
)}
</CardContent>
</Card>
{/* Plan limits */}
<Card>
<CardHeader>
<CardTitle>Limites del plan</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-lg border p-4">
<div className="flex items-center gap-2">
<Users className="h-5 w-5 text-gray-400" />
<span className="text-sm text-gray-500">Usuarios</span>
</div>
<p className="mt-2 text-2xl font-semibold">{tenant.maxUsers}</p>
</div>
<div className="rounded-lg border p-4">
<div className="flex items-center gap-2">
<HardDrive className="h-5 w-5 text-gray-400" />
<span className="text-sm text-gray-500">Almacenamiento</span>
</div>
<p className="mt-2 text-2xl font-semibold">{tenant.maxStorageGb} GB</p>
</div>
<div className="rounded-lg border p-4">
<div className="flex items-center gap-2">
<Activity className="h-5 w-5 text-gray-400" />
<span className="text-sm text-gray-500">API calls/mes</span>
</div>
<p className="mt-2 text-2xl font-semibold">{tenant.maxApiCallsMonthly.toLocaleString()}</p>
</div>
<div className="rounded-lg border p-4">
<div className="flex items-center gap-2">
<Building2 className="h-5 w-5 text-gray-400" />
<span className="text-sm text-gray-500">Sucursales</span>
</div>
<p className="mt-2 text-2xl font-semibold">{tenant.maxBranches}</p>
</div>
</div>
</CardContent>
</Card>
</div>
</TabPanel>
{/* Usage Tab */}
<TabPanel value="usage">
<div className="space-y-6">
{/* Current usage */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<UsageCard
label="Usuarios"
current={stats.totalUsers}
max={tenant.maxUsers}
icon={Users}
/>
<UsageCard
label="Almacenamiento"
current={stats.storageUsedGb}
max={tenant.maxStorageGb}
unit="GB"
icon={HardDrive}
/>
<UsageCard
label="API calls (mes)"
current={stats.apiCallsThisMonth}
max={tenant.maxApiCallsMonthly}
icon={Activity}
/>
<UsageCard
label="Sucursales"
current={stats.totalBranches}
max={tenant.maxBranches}
icon={Building2}
/>
</div>
{/* Activity metrics */}
<Card>
<CardHeader>
<CardTitle>Metricas de actividad</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="text-center">
<p className="text-3xl font-bold text-gray-900">{stats.sessionsToday}</p>
<p className="text-sm text-gray-500">Sesiones hoy</p>
</div>
<div className="text-center">
<p className="text-3xl font-bold text-gray-900">{stats.sessionsThisWeek}</p>
<p className="text-sm text-gray-500">Sesiones esta semana</p>
</div>
<div className="text-center">
<p className="text-3xl font-bold text-gray-900">{stats.sessionsThisMonth}</p>
<p className="text-sm text-gray-500">Sesiones este mes</p>
</div>
<div className="text-center">
<p className="text-3xl font-bold text-gray-900">{stats.avgSessionDurationMinutes} min</p>
<p className="text-sm text-gray-500">Duracion promedio</p>
</div>
</div>
</CardContent>
</Card>
{/* Usage charts */}
<Card>
<CardHeader>
<CardTitle>Llamadas API</CardTitle>
</CardHeader>
<CardContent>
<PerformanceChart
data={usageChartData}
type="bar"
height={300}
loading={historyLoading}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Almacenamiento</CardTitle>
</CardHeader>
<CardContent>
<PerformanceChart
data={usageChartData.map(d => ({ ...d, value: d.storage }))}
type="line"
height={250}
loading={historyLoading}
/>
</CardContent>
</Card>
</div>
</TabPanel>
{/* Settings Tab */}
<TabPanel value="settings">
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5" />
Dominio y Acceso
</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-3">
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Dominio</dt>
<dd className="text-sm font-medium text-gray-900">
{tenant.domain || 'No configurado'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Subdominio</dt>
<dd className="text-sm font-medium text-gray-900">
{tenant.subdomain || tenant.slug}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Zona horaria</dt>
<dd className="text-sm font-medium text-gray-900">
{tenant.timezone || 'UTC'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Idioma</dt>
<dd className="text-sm font-medium text-gray-900">
{tenant.locale || 'en-US'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Moneda</dt>
<dd className="text-sm font-medium text-gray-900">
{tenant.currency || 'USD'}
</dd>
</div>
</dl>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Caracteristicas
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-2">
<FeatureBadge label="Reportes avanzados" enabled={tenant.features.advancedReporting ?? false} />
<FeatureBadge label="Acceso API" enabled={tenant.features.apiAccess ?? false} />
<FeatureBadge label="Marca personalizada" enabled={tenant.features.customBranding ?? false} />
<FeatureBadge label="SSO" enabled={tenant.features.sso ?? false} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Zap className="h-5 w-5" />
Modulos habilitados
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{tenant.enabledModules.map((module) => (
<Badge key={module} variant="primary">
{module.charAt(0).toUpperCase() + module.slice(1)}
</Badge>
))}
</div>
</CardContent>
</Card>
</div>
</TabPanel>
</TabPanels>
</Tabs>
</div>
{/* Activity timeline sidebar */}
<div className="lg:col-span-1">
<Card className="sticky top-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Informacion del sistema
</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-4">
<div>
<dt className="text-sm text-gray-500">ID</dt>
<dd className="font-mono text-xs text-gray-900 break-all">{tenant.id}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Slug</dt>
<dd className="text-sm font-medium text-gray-900">{tenant.slug}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Creado</dt>
<dd className="text-sm text-gray-900">{formatDate(tenant.createdAt, 'full')}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Actualizado</dt>
<dd className="text-sm text-gray-900">{formatDate(tenant.updatedAt, 'full')}</dd>
</div>
{tenant.suspendedAt && (
<div>
<dt className="text-sm text-danger-500">Suspendido</dt>
<dd className="text-sm text-danger-700">{formatDate(tenant.suspendedAt, 'full')}</dd>
{tenant.suspensionReason && (
<dd className="text-xs text-danger-600 mt-1">{tenant.suspensionReason}</dd>
)}
</div>
)}
</dl>
{/* Quick stats */}
<div className="mt-6 pt-6 border-t">
<h4 className="text-sm font-medium text-gray-900 mb-3">Metricas rapidas</h4>
<dl className="space-y-2">
<div className="flex justify-between text-sm">
<dt className="text-gray-500">Usuarios activos</dt>
<dd className="font-medium">{stats.activeUsers}</dd>
</div>
<div className="flex justify-between text-sm">
<dt className="text-gray-500">Documentos</dt>
<dd className="font-medium">{stats.totalDocuments.toLocaleString()}</dd>
</div>
<div className="flex justify-between text-sm">
<dt className="text-gray-500">Transacciones</dt>
<dd className="font-medium">{stats.totalTransactions.toLocaleString()}</dd>
</div>
</dl>
</div>
</CardContent>
</Card>
</div>
</div>
{/* Delete Modal */}
<ConfirmModal
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
onConfirm={handleDelete}
title="Eliminar tenant"
message={`¿Estas seguro de que deseas eliminar "${tenant.name}"? Esta accion eliminara todos los datos asociados y no se puede deshacer.`}
variant="danger"
confirmText="Eliminar"
isLoading={isProcessing}
/>
{/* Suspend Modal */}
<ConfirmModal
isOpen={showSuspendModal}
onClose={() => setShowSuspendModal(false)}
onConfirm={handleSuspend}
title="Suspender tenant"
message={`¿Estas seguro de que deseas suspender "${tenant.name}"? Los usuarios no podran acceder hasta que se reactive.`}
variant="warning"
confirmText="Suspender"
isLoading={isProcessing}
/>
</div>
);
}
// ==================== Helper Components ====================
interface UsageCardProps {
label: string;
current: number;
max: number;
unit?: string;
icon: React.ComponentType<{ className?: string }>;
}
function UsageCard({ label, current, max, unit = '', icon: Icon }: UsageCardProps) {
const percent = max > 0 ? Math.round((current / max) * 100) : 0;
const isWarning = percent >= 80;
const isCritical = percent >= 95;
return (
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Icon className="h-5 w-5 text-gray-400" />
<span className="text-sm text-gray-500">{label}</span>
</div>
{isWarning && (
isCritical ? (
<TrendingDown className="h-4 w-4 text-danger-500" />
) : (
<TrendingUp className="h-4 w-4 text-warning-500" />
)
)}
</div>
<div className="mt-2">
<span className="text-2xl font-semibold">
{typeof current === 'number' && current % 1 !== 0 ? current.toFixed(1) : current.toLocaleString()}
</span>
<span className="text-gray-500"> / {max.toLocaleString()} {unit}</span>
</div>
<div className="mt-3">
<div className="h-2 rounded-full bg-gray-200">
<div
className={cn(
'h-2 rounded-full transition-all',
isCritical ? 'bg-danger-500' : isWarning ? 'bg-warning-500' : 'bg-primary-500'
)}
style={{ width: `${Math.min(percent, 100)}%` }}
/>
</div>
<p className="mt-1 text-xs text-gray-500">{percent}% usado</p>
</div>
</CardContent>
</Card>
);
}
function FeatureBadge({ label, enabled }: { label: string; enabled: boolean }) {
return (
<div className={cn(
'flex items-center justify-between rounded-lg border p-3',
enabled ? 'bg-success-50 border-success-200' : 'bg-gray-50 border-gray-200'
)}>
<span className="text-sm font-medium">{label}</span>
<Badge variant={enabled ? 'success' : 'default'}>
{enabled ? 'Activo' : 'Inactivo'}
</Badge>
</div>
);
}
export default TenantDetailPage;

View File

@ -0,0 +1,652 @@
/**
* TenantEditPage - Superadmin Portal
* Edit an existing tenant's configuration
*/
import { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { ArrowLeft, Building2, AlertTriangle, CheckCircle, Clock, Pause, XCircle } from 'lucide-react';
import { Button } from '@components/atoms/Button';
import { Input } from '@components/atoms/Input';
import { Label } from '@components/atoms/Label';
import { Badge } from '@components/atoms/Badge';
import { Switch } from '@components/atoms/Switch';
import { Spinner } from '@components/atoms/Spinner';
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 { ErrorEmptyState } from '@components/templates/EmptyState';
import { useTenant, useTenants } from '@features/superadmin';
import type { UpdateTenantDto, TenantStatus } from '@features/superadmin';
import { formatDate } from '@utils/formatters';
// ==================== Status Badge ====================
const STATUS_CONFIG: Record<
TenantStatus,
{ label: string; variant: 'success' | 'info' | 'danger' | 'default' | 'warning'; icon: typeof CheckCircle }
> = {
active: { label: 'Activo', variant: 'success', icon: CheckCircle },
trial: { label: 'Prueba', variant: 'info', icon: Clock },
suspended: { label: 'Suspendido', variant: 'danger', icon: Pause },
cancelled: { label: 'Cancelado', variant: 'default', icon: XCircle },
pending: { label: 'Pendiente', variant: 'warning', icon: AlertTriangle },
};
function TenantStatusBadge({ status }: { status: TenantStatus }) {
const config = STATUS_CONFIG[status] || STATUS_CONFIG.pending;
const Icon = config.icon;
return (
<Badge variant={config.variant} className="inline-flex items-center gap-1">
<Icon className="h-3 w-3" />
{config.label}
</Badge>
);
}
// ==================== Form Types ====================
interface FormData {
name: string;
slug: string;
domain: string;
subdomain: string;
ownerName: string;
ownerEmail: string;
phone: string;
timezone: string;
locale: string;
currency: string;
maxUsers: number;
maxBranches: number;
maxStorageGb: number;
maxApiCallsMonthly: 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;
modulePos: boolean;
}
// ==================== Options ====================
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: 'America/Denver', label: 'America/Denver (UTC-7)' },
{ 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 TenantEditPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { showToast } = useToast();
const { tenant, isLoading, error, refresh } = useTenant(id);
const { updateTenant } = useTenants();
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const {
register,
handleSubmit,
watch,
setValue,
reset,
formState: { errors, isDirty },
} = useForm<FormData>();
// Populate form when tenant loads
useEffect(() => {
if (tenant) {
reset({
name: tenant.name,
slug: tenant.slug,
domain: tenant.domain || '',
subdomain: tenant.subdomain || '',
ownerName: tenant.ownerName,
ownerEmail: tenant.ownerEmail,
phone: tenant.phone || '',
timezone: tenant.timezone || 'UTC',
locale: tenant.locale || 'en-US',
currency: tenant.currency || 'USD',
maxUsers: tenant.maxUsers,
maxBranches: tenant.maxBranches,
maxStorageGb: tenant.maxStorageGb,
maxApiCallsMonthly: tenant.maxApiCallsMonthly,
advancedReporting: tenant.features.advancedReporting || false,
apiAccess: tenant.features.apiAccess || false,
customBranding: tenant.features.customBranding || false,
sso: tenant.features.sso || false,
moduleCrm: tenant.enabledModules.includes('crm'),
moduleInventory: tenant.enabledModules.includes('inventory'),
moduleSales: tenant.enabledModules.includes('sales'),
modulePurchases: tenant.enabledModules.includes('purchases'),
moduleHr: tenant.enabledModules.includes('hr'),
moduleAccounting: tenant.enabledModules.includes('accounting'),
moduleProjects: tenant.enabledModules.includes('projects'),
modulePos: tenant.enabledModules.includes('pos'),
});
}
}, [tenant, reset]);
const onFormSubmit = async (data: FormData) => {
if (!id) return;
setIsSubmitting(true);
setSubmitError(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');
if (data.modulePos) enabledModules.push('pos');
const updateData: UpdateTenantDto = {
name: data.name,
slug: data.slug,
domain: data.domain || null,
subdomain: data.subdomain || null,
ownerName: data.ownerName,
ownerEmail: data.ownerEmail,
phone: data.phone || null,
timezone: data.timezone,
locale: data.locale,
currency: data.currency,
maxUsers: data.maxUsers,
maxBranches: data.maxBranches,
maxStorageGb: data.maxStorageGb,
maxApiCallsMonthly: data.maxApiCallsMonthly,
enabledModules,
features: {
advancedReporting: data.advancedReporting,
apiAccess: data.apiAccess,
customBranding: data.customBranding,
sso: data.sso,
},
};
await updateTenant(id, updateData);
showToast({
type: 'success',
title: 'Tenant actualizado',
message: 'Los cambios han sido guardados exitosamente.',
});
navigate(`/superadmin/tenants/${id}`);
} catch (err) {
const message = err instanceof Error ? err.message : 'Error al actualizar tenant';
setSubmitError(message);
} finally {
setIsSubmitting(false);
}
};
// ==================== Loading / Error States ====================
if (isLoading) {
return (
<div className="flex h-96 items-center justify-center">
<Spinner size="lg" />
</div>
);
}
if (error || !tenant) {
return (
<div className="p-6">
<ErrorEmptyState
title="Tenant no encontrado"
description="No se pudo cargar la informacion del tenant."
onRetry={refresh}
/>
</div>
);
}
// ==================== Render ====================
return (
<div className="space-y-6 p-6">
{/* Breadcrumbs */}
<Breadcrumbs
items={[
{ label: 'Superadmin', href: '/superadmin' },
{ label: 'Tenants', href: '/superadmin/tenants' },
{ label: tenant.name, href: `/superadmin/tenants/${id}` },
{ label: 'Editar' },
]}
/>
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => navigate(`/superadmin/tenants/${id}`)}>
<ArrowLeft className="mr-2 h-4 w-4" />
Volver
</Button>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-100">
<Building2 className="h-6 w-6 text-primary-600" />
</div>
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-gray-900">Editar tenant</h1>
<TenantStatusBadge status={tenant.status} />
</div>
<p className="text-sm text-gray-500">
Modifica la configuracion de {tenant.name}
</p>
</div>
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-6">
<div className="grid gap-6 lg:grid-cols-3">
{/* Main form */}
<div className="lg:col-span-2 space-y-6">
{submitError && (
<Alert
variant="danger"
title="Error"
onClose={() => setSubmitError(null)}
>
{submitError}
</Alert>
)}
{/* Basic Information */}
<Card>
<CardHeader>
<CardTitle>Informacion basica</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<FormField
label="Nombre del tenant"
error={errors.name?.message}
required
>
<Input
{...register('name', { required: 'El nombre es requerido' })}
placeholder="Mi Empresa S.A."
/>
</FormField>
<FormField
label="Slug (URL)"
error={errors.slug?.message}
required
>
<Input
{...register('slug', {
required: 'El slug es requerido',
pattern: {
value: /^[a-z0-9-]+$/,
message: 'Solo letras minusculas, numeros y guiones',
},
})}
placeholder="mi-empresa"
/>
</FormField>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<FormField label="Dominio personalizado">
<Input
{...register('domain')}
placeholder="app.miempresa.com"
/>
</FormField>
<FormField label="Subdominio">
<Input
{...register('subdomain')}
placeholder="miempresa"
/>
</FormField>
</div>
</CardContent>
</Card>
{/* Owner Information */}
<Card>
<CardHeader>
<CardTitle>Propietario</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField
label="Nombre completo"
error={errors.ownerName?.message}
required
>
<Input
{...register('ownerName', { required: 'El nombre es requerido' })}
placeholder="Juan Perez"
/>
</FormField>
<div className="grid gap-4 sm:grid-cols-2">
<FormField
label="Email"
error={errors.ownerEmail?.message}
required
>
<Input
type="email"
{...register('ownerEmail', {
required: 'El email es requerido',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Email invalido',
},
})}
placeholder="admin@empresa.com"
/>
</FormField>
<FormField label="Telefono">
<Input
{...register('phone')}
placeholder="+52 55 1234 5678"
/>
</FormField>
</div>
</CardContent>
</Card>
{/* Limits */}
<Card>
<CardHeader>
<CardTitle>Limites</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<FormField label="Max. usuarios">
<Input
type="number"
{...register('maxUsers', { valueAsNumber: true, min: 1 })}
min={1}
/>
</FormField>
<FormField label="Max. sucursales">
<Input
type="number"
{...register('maxBranches', { valueAsNumber: true, min: 1 })}
min={1}
/>
</FormField>
<FormField label="Almacenamiento (GB)">
<Input
type="number"
{...register('maxStorageGb', { valueAsNumber: true, min: 1 })}
min={1}
/>
</FormField>
<FormField label="API calls/mes">
<Input
type="number"
{...register('maxApiCallsMonthly', { valueAsNumber: true, min: 1000 })}
min={1000}
step={1000}
/>
</FormField>
</div>
</CardContent>
</Card>
{/* Regional Settings */}
<Card>
<CardHeader>
<CardTitle>Configuracion regional</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-3">
<FormField label="Zona horaria">
<Select
options={TIMEZONE_OPTIONS}
value={watch('timezone')}
onChange={(value) => setValue('timezone', value as string)}
/>
</FormField>
<FormField label="Idioma">
<Select
options={LOCALE_OPTIONS}
value={watch('locale')}
onChange={(value) => setValue('locale', value as string)}
/>
</FormField>
<FormField label="Moneda">
<Select
options={CURRENCY_OPTIONS}
value={watch('currency')}
onChange={(value) => setValue('currency', value as string)}
/>
</FormField>
</div>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Tenant info summary */}
<Card>
<CardHeader>
<CardTitle>Resumen</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-3">
<div>
<dt className="text-sm text-gray-500">ID</dt>
<dd className="font-mono text-xs text-gray-900 break-all">{tenant.id}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Plan</dt>
<dd className="text-sm font-medium text-gray-900">{tenant.planName || tenant.planTier}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Creado</dt>
<dd className="text-sm text-gray-900">{formatDate(tenant.createdAt, 'full')}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Actualizado</dt>
<dd className="text-sm text-gray-900">{formatDate(tenant.updatedAt, 'full')}</dd>
</div>
</dl>
</CardContent>
</Card>
{/* Features */}
<Card>
<CardHeader>
<CardTitle>Caracteristicas</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="advancedReporting">Reportes avanzados</Label>
<Switch
id="advancedReporting"
checked={watch('advancedReporting')}
onChange={(checked) => setValue('advancedReporting', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="apiAccess">Acceso API</Label>
<Switch
id="apiAccess"
checked={watch('apiAccess')}
onChange={(checked) => setValue('apiAccess', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="customBranding">Marca personalizada</Label>
<Switch
id="customBranding"
checked={watch('customBranding')}
onChange={(checked) => setValue('customBranding', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="sso">SSO</Label>
<Switch
id="sso"
checked={watch('sso')}
onChange={(checked) => setValue('sso', checked)}
/>
</div>
</CardContent>
</Card>
{/* Modules */}
<Card>
<CardHeader>
<CardTitle>Modulos</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="moduleCrm">CRM</Label>
<Switch
id="moduleCrm"
checked={watch('moduleCrm')}
onChange={(checked) => setValue('moduleCrm', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="moduleInventory">Inventario</Label>
<Switch
id="moduleInventory"
checked={watch('moduleInventory')}
onChange={(checked) => setValue('moduleInventory', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="moduleSales">Ventas</Label>
<Switch
id="moduleSales"
checked={watch('moduleSales')}
onChange={(checked) => setValue('moduleSales', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="modulePurchases">Compras</Label>
<Switch
id="modulePurchases"
checked={watch('modulePurchases')}
onChange={(checked) => setValue('modulePurchases', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="moduleHr">Recursos Humanos</Label>
<Switch
id="moduleHr"
checked={watch('moduleHr')}
onChange={(checked) => setValue('moduleHr', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="moduleAccounting">Contabilidad</Label>
<Switch
id="moduleAccounting"
checked={watch('moduleAccounting')}
onChange={(checked) => setValue('moduleAccounting', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="moduleProjects">Proyectos</Label>
<Switch
id="moduleProjects"
checked={watch('moduleProjects')}
onChange={(checked) => setValue('moduleProjects', checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="modulePos">Punto de Venta</Label>
<Switch
id="modulePos"
checked={watch('modulePos')}
onChange={(checked) => setValue('modulePos', checked)}
/>
</div>
</CardContent>
</Card>
{/* Warning card */}
<Card className="border-warning-200 bg-warning-50">
<CardContent className="pt-6">
<h3 className="font-medium text-warning-800 mb-2">Nota importante</h3>
<p className="text-sm text-warning-700">
Algunos cambios pueden afectar la experiencia de los usuarios del tenant.
Considera notificar al propietario antes de realizar cambios significativos.
</p>
</CardContent>
</Card>
{/* Actions */}
<div className="flex flex-col gap-3">
<Button type="submit" disabled={isSubmitting || !isDirty} className="w-full">
{isSubmitting ? 'Guardando...' : 'Guardar cambios'}
</Button>
<Button
type="button"
variant="outline"
onClick={() => navigate(`/superadmin/tenants/${id}`)}
className="w-full"
>
Cancelar
</Button>
</div>
</div>
</div>
</form>
</div>
);
}
export default TenantEditPage;

View File

@ -0,0 +1,500 @@
/**
* TenantsListPage - Superadmin Portal
* Main page for managing all tenants in the system
*/
import { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Plus,
MoreVertical,
Eye,
Edit,
Pause,
Play,
Trash2,
Building2,
Users,
Search,
AlertTriangle,
CheckCircle,
Clock,
XCircle,
} from 'lucide-react';
import { Button } from '@components/atoms/Button';
import { Input } from '@components/atoms/Input';
import { Badge } from '@components/atoms/Badge';
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
import { DataTable, type Column } from '@components/organisms/DataTable';
import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown';
import { Select, type SelectOption } from '@components/organisms/Select';
import { ConfirmModal } from '@components/organisms/Modal';
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
import { NoDataEmptyState, ErrorEmptyState, NoResultsEmptyState } from '@components/templates/EmptyState';
import { StatCard } from '@components/organisms/DashboardWidgets/StatCard';
import { useTenants } from '@features/superadmin';
import type { Tenant, TenantStatus, TenantPlanTier, SuspendTenantDto } from '@features/superadmin';
import { formatDate } from '@utils/formatters';
// ==================== Status Badge Configuration ====================
const STATUS_CONFIG: Record<
TenantStatus,
{ label: string; variant: 'success' | 'info' | 'danger' | 'default' | 'warning'; icon: typeof CheckCircle }
> = {
active: { label: 'Activo', variant: 'success', icon: CheckCircle },
trial: { label: 'Prueba', variant: 'info', icon: Clock },
suspended: { label: 'Suspendido', variant: 'danger', icon: Pause },
cancelled: { label: 'Cancelado', variant: 'default', icon: XCircle },
pending: { label: 'Pendiente', variant: 'warning', icon: AlertTriangle },
};
const PLAN_LABELS: Record<TenantPlanTier, string> = {
free: 'Gratis',
starter: 'Starter',
professional: 'Profesional',
enterprise: 'Enterprise',
custom: 'Personalizado',
};
// ==================== Status Badge Component ====================
interface StatusBadgeProps {
status: TenantStatus;
}
function StatusBadge({ status }: StatusBadgeProps) {
const config = STATUS_CONFIG[status] || STATUS_CONFIG.pending;
const Icon = config.icon;
return (
<Badge variant={config.variant} className="inline-flex items-center gap-1">
<Icon className="h-3 w-3" />
{config.label}
</Badge>
);
}
// ==================== Filter Options ====================
const STATUS_OPTIONS: SelectOption[] = [
{ value: '', label: 'Todos los estados' },
{ value: 'active', label: 'Activo' },
{ value: 'trial', label: 'Prueba' },
{ value: 'suspended', label: 'Suspendido' },
{ value: 'cancelled', label: 'Cancelado' },
{ value: 'pending', label: 'Pendiente' },
];
const PLAN_OPTIONS: SelectOption[] = [
{ value: '', label: 'Todos los planes' },
{ value: 'free', label: 'Gratis' },
{ value: 'starter', label: 'Starter' },
{ value: 'professional', label: 'Profesional' },
{ value: 'enterprise', label: 'Enterprise' },
{ value: 'custom', label: 'Personalizado' },
];
// ==================== Main Component ====================
export function TenantsListPage() {
const navigate = useNavigate();
const [searchInput, setSearchInput] = useState('');
const [tenantToDelete, setTenantToDelete] = useState<Tenant | null>(null);
const [tenantToSuspend, setTenantToSuspend] = useState<Tenant | null>(null);
const [tenantToActivate, setTenantToActivate] = useState<Tenant | null>(null);
const {
tenants,
total,
page,
totalPages,
isLoading,
error,
filters,
setFilters,
deleteTenant,
suspendTenant,
activateTenant,
refresh,
} = useTenants({ page: 1, limit: 10, sortBy: 'createdAt', sortOrder: 'desc' });
// Calculate stats from tenants
const stats = useMemo(() => {
// Note: In production, these would come from the API
// For now, we calculate from loaded tenants (which is a subset)
return {
total: total,
active: tenants.filter((t) => t.status === 'active').length,
trial: tenants.filter((t) => t.status === 'trial').length,
suspended: tenants.filter((t) => t.status === 'suspended').length,
};
}, [tenants, total]);
// ==================== Handlers ====================
const handleSearch = () => {
setFilters({ ...filters, search: searchInput, page: 1 });
};
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSearch();
}
};
const handleStatusChange = (value: string | string[]) => {
const status = (value as string) || undefined;
setFilters({ ...filters, status: status as TenantStatus | undefined, page: 1 });
};
const handlePlanChange = (value: string | string[]) => {
const planTier = (value as string) || undefined;
setFilters({ ...filters, planTier: planTier as TenantPlanTier | undefined, page: 1 });
};
const handlePageChange = (newPage: number) => {
setFilters({ ...filters, page: newPage });
};
const handleSort = (key: string) => {
const newOrder = filters.sortBy === key && filters.sortOrder === 'asc' ? 'desc' : 'asc';
setFilters({ ...filters, sortBy: key as typeof filters.sortBy, sortOrder: newOrder });
};
const handleDeleteConfirm = async () => {
if (tenantToDelete) {
await deleteTenant(tenantToDelete.id);
setTenantToDelete(null);
}
};
const handleSuspendConfirm = async () => {
if (tenantToSuspend) {
const suspendData: SuspendTenantDto = {
reason: 'Suspended by superadmin',
suspendImmediately: true,
notifyOwner: true,
};
await suspendTenant(tenantToSuspend.id, suspendData);
setTenantToSuspend(null);
}
};
const handleActivateConfirm = async () => {
if (tenantToActivate) {
await activateTenant(tenantToActivate.id, { notifyOwner: true });
setTenantToActivate(null);
}
};
const clearFilters = () => {
setSearchInput('');
setFilters({ page: 1, limit: 10, sortBy: 'createdAt', sortOrder: 'desc' });
};
// ==================== Actions Menu ====================
const getActionsMenu = (tenant: Tenant): DropdownItem[] => {
const items: DropdownItem[] = [
{
key: 'view',
label: 'Ver detalle',
icon: <Eye className="h-4 w-4" />,
onClick: () => navigate(`/superadmin/tenants/${tenant.id}`),
},
{
key: 'edit',
label: 'Editar',
icon: <Edit className="h-4 w-4" />,
onClick: () => navigate(`/superadmin/tenants/${tenant.id}/edit`),
},
];
// Add suspend/activate based on current status
if (tenant.status === 'active' || tenant.status === 'trial') {
items.push({
key: 'suspend',
label: 'Suspender',
icon: <Pause className="h-4 w-4" />,
danger: true,
onClick: () => setTenantToSuspend(tenant),
});
}
if (tenant.status === 'suspended') {
items.push({
key: 'activate',
label: 'Reactivar',
icon: <Play className="h-4 w-4" />,
onClick: () => setTenantToActivate(tenant),
});
}
items.push({
key: 'delete',
label: 'Eliminar',
icon: <Trash2 className="h-4 w-4" />,
danger: true,
onClick: () => setTenantToDelete(tenant),
});
return items;
};
// ==================== Table Columns ====================
const columns: Column<Tenant>[] = [
{
key: 'tenant',
header: 'Tenant',
render: (tenant) => (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-100">
<Building2 className="h-5 w-5 text-primary-600" />
</div>
<div>
<div className="font-medium text-gray-900">{tenant.name}</div>
<div className="text-sm text-gray-500">{tenant.domain || tenant.subdomain || tenant.slug}</div>
</div>
</div>
),
},
{
key: 'planTier',
header: 'Plan',
sortable: true,
render: (tenant) => (
<span className="inline-flex rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-700">
{PLAN_LABELS[tenant.planTier] || tenant.planName || tenant.planTier}
</span>
),
},
{
key: 'status',
header: 'Estado',
sortable: true,
render: (tenant) => <StatusBadge status={tenant.status} />,
},
{
key: 'users',
header: 'Usuarios',
render: (tenant) => (
<div className="flex items-center gap-1 text-sm text-gray-600">
<Users className="h-4 w-4 text-gray-400" />
<span>{tenant.maxUsers}</span>
</div>
),
},
{
key: 'createdAt',
header: 'Creado',
sortable: true,
render: (tenant) => (
<span className="text-sm text-gray-500">{formatDate(tenant.createdAt, 'short')}</span>
),
},
{
key: 'actions',
header: '',
align: 'right',
render: (tenant) => (
<Dropdown
trigger={
<button className="rounded p-1 hover:bg-gray-100">
<MoreVertical className="h-4 w-4 text-gray-500" />
</button>
}
items={getActionsMenu(tenant)}
align="right"
/>
),
},
];
// ==================== Render ====================
if (error) {
return (
<div className="p-6">
<ErrorEmptyState onRetry={refresh} />
</div>
);
}
const hasFilters = filters.search || filters.status || filters.planTier;
return (
<div className="space-y-6 p-6">
{/* Breadcrumbs */}
<Breadcrumbs
items={[
{ label: 'Superadmin', href: '/superadmin' },
{ label: 'Tenants' },
]}
/>
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Tenants</h1>
<p className="text-sm text-gray-500">
Administra todos los tenants de la plataforma
</p>
</div>
<Button onClick={() => navigate('/superadmin/tenants/new')}>
<Plus className="mr-2 h-4 w-4" />
Nuevo Tenant
</Button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Total Tenants"
value={stats.total}
icon={Building2}
loading={isLoading && tenants.length === 0}
/>
<StatCard
title="Activos"
value={stats.active}
icon={CheckCircle}
loading={isLoading && tenants.length === 0}
/>
<StatCard
title="En Prueba"
value={stats.trial}
icon={Clock}
loading={isLoading && tenants.length === 0}
/>
<StatCard
title="Suspendidos"
value={stats.suspended}
icon={AlertTriangle}
loading={isLoading && tenants.length === 0}
/>
</div>
{/* Main Content Card */}
<Card>
<CardHeader>
<CardTitle>Lista de Tenants</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Filters */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
{/* Search */}
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
type="text"
placeholder="Buscar por nombre, dominio o email..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={handleSearchKeyDown}
className="pl-10"
/>
</div>
{/* Status Filter */}
<div className="w-full sm:w-48">
<Select
options={STATUS_OPTIONS}
value={filters.status || ''}
onChange={handleStatusChange}
placeholder="Estado"
/>
</div>
{/* Plan Filter */}
<div className="w-full sm:w-48">
<Select
options={PLAN_OPTIONS}
value={filters.planTier || ''}
onChange={handlePlanChange}
placeholder="Plan"
/>
</div>
{/* Search Button */}
<Button variant="outline" onClick={handleSearch}>
<Search className="mr-2 h-4 w-4" />
Buscar
</Button>
</div>
{/* Table or Empty State */}
{tenants.length === 0 && !isLoading ? (
hasFilters ? (
<NoResultsEmptyState
searchTerm={filters.search}
onClearSearch={clearFilters}
/>
) : (
<NoDataEmptyState
entityName="tenants"
onCreateNew={() => navigate('/superadmin/tenants/new')}
/>
)
) : (
<DataTable
data={tenants}
columns={columns}
isLoading={isLoading}
pagination={{
page,
totalPages,
total,
limit: filters.limit || 10,
onPageChange: handlePageChange,
}}
sorting={{
sortBy: filters.sortBy || null,
sortOrder: (filters.sortOrder as 'asc' | 'desc') || 'desc',
onSort: handleSort,
}}
/>
)}
</div>
</CardContent>
</Card>
{/* Delete Confirmation Modal */}
<ConfirmModal
isOpen={!!tenantToDelete}
onClose={() => 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 */}
<ConfirmModal
isOpen={!!tenantToSuspend}
onClose={() => 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 */}
<ConfirmModal
isOpen={!!tenantToActivate}
onClose={() => setTenantToActivate(null)}
onConfirm={handleActivateConfirm}
title="Reactivar tenant"
message={`¿Deseas reactivar "${tenantToActivate?.name}"? Los usuarios podran acceder nuevamente.`}
variant="success"
confirmText="Reactivar"
/>
</div>
);
}
export default TenantsListPage;

View File

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