[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:
parent
b6dd94abcb
commit
29c76fcbd6
386
src/app/layouts/SuperadminLayout.tsx
Normal file
386
src/app/layouts/SuperadminLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,2 +1,3 @@
|
|||||||
export * from './AuthLayout';
|
export * from './AuthLayout';
|
||||||
export * from './DashboardLayout';
|
export * from './DashboardLayout';
|
||||||
|
export * from './SuperadminLayout';
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
1
src/features/superadmin/api/index.ts
Normal file
1
src/features/superadmin/api/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { tenantsApi, type TenantDashboardStats } from './tenants.api';
|
||||||
133
src/features/superadmin/api/tenants.api.ts
Normal file
133
src/features/superadmin/api/tenants.api.ts
Normal 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`);
|
||||||
|
},
|
||||||
|
};
|
||||||
23
src/features/superadmin/hooks/index.ts
Normal file
23
src/features/superadmin/hooks/index.ts
Normal 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';
|
||||||
417
src/features/superadmin/hooks/useSuperadminStats.ts
Normal file
417
src/features/superadmin/hooks/useSuperadminStats.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
818
src/features/superadmin/hooks/useTenants.ts
Normal file
818
src/features/superadmin/hooks/useTenants.ts
Normal 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 };
|
||||||
|
}
|
||||||
13
src/features/superadmin/index.ts
Normal file
13
src/features/superadmin/index.ts
Normal 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';
|
||||||
6
src/features/superadmin/types/index.ts
Normal file
6
src/features/superadmin/types/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Superadmin Types - Index
|
||||||
|
* Exports all types for the superadmin portal
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './tenant';
|
||||||
282
src/features/superadmin/types/tenant.ts
Normal file
282
src/features/superadmin/types/tenant.ts
Normal 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;
|
||||||
|
}
|
||||||
771
src/pages/superadmin/SuperadminDashboardPage.tsx
Normal file
771
src/pages/superadmin/SuperadminDashboardPage.tsx
Normal 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;
|
||||||
14
src/pages/superadmin/index.ts
Normal file
14
src/pages/superadmin/index.ts
Normal 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';
|
||||||
683
src/pages/superadmin/tenants/TenantCreatePage.tsx
Normal file
683
src/pages/superadmin/tenants/TenantCreatePage.tsx
Normal 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;
|
||||||
850
src/pages/superadmin/tenants/TenantDetailPage.tsx
Normal file
850
src/pages/superadmin/tenants/TenantDetailPage.tsx
Normal 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;
|
||||||
652
src/pages/superadmin/tenants/TenantEditPage.tsx
Normal file
652
src/pages/superadmin/tenants/TenantEditPage.tsx
Normal 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;
|
||||||
500
src/pages/superadmin/tenants/TenantsListPage.tsx
Normal file
500
src/pages/superadmin/tenants/TenantsListPage.tsx
Normal 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;
|
||||||
9
src/pages/superadmin/tenants/index.ts
Normal file
9
src/pages/superadmin/tenants/index.ts
Normal 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';
|
||||||
Loading…
Reference in New Issue
Block a user