From d22e898909a56abf8fb003fd90db952d7447862d Mon Sep 17 00:00:00 2001 From: Consultoria AS Date: Sun, 15 Mar 2026 23:48:23 +0000 Subject: [PATCH] feat: add subscription UI, plan-based nav gating, and client subscription page - Add plan field to UserInfo shared type - Subscription API client and React Query hooks - Client subscription page with status + payment history - Sidebar navigation filtered by tenant plan features - Subscription link added to navigation Co-Authored-By: Claude Opus 4.6 --- apps/api/src/services/auth.service.ts | 2 + .../configuracion/suscripcion/page.tsx | 127 ++++++++++++++++++ apps/web/components/layouts/sidebar.tsx | 30 ++++- apps/web/lib/api/subscription.ts | 44 ++++++ apps/web/lib/hooks/use-subscription.ts | 40 ++++++ packages/shared/src/types/auth.ts | 1 + 6 files changed, 237 insertions(+), 7 deletions(-) create mode 100644 apps/web/app/(dashboard)/configuracion/suscripcion/page.tsx create mode 100644 apps/web/lib/api/subscription.ts create mode 100644 apps/web/lib/hooks/use-subscription.ts diff --git a/apps/api/src/services/auth.service.ts b/apps/api/src/services/auth.service.ts index 83cd1f9..30e1fbd 100644 --- a/apps/api/src/services/auth.service.ts +++ b/apps/api/src/services/auth.service.ts @@ -77,6 +77,7 @@ export async function register(data: RegisterRequest): Promise { tenantId: tenant.id, tenantName: tenant.nombre, tenantRfc: tenant.rfc, + plan: tenant.plan, }, }; } @@ -140,6 +141,7 @@ export async function login(data: LoginRequest): Promise { tenantId: user.tenantId, tenantName: user.tenant.nombre, tenantRfc: user.tenant.rfc, + plan: user.tenant.plan, }, }; } diff --git a/apps/web/app/(dashboard)/configuracion/suscripcion/page.tsx b/apps/web/app/(dashboard)/configuracion/suscripcion/page.tsx new file mode 100644 index 0000000..694b466 --- /dev/null +++ b/apps/web/app/(dashboard)/configuracion/suscripcion/page.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { Header } from '@/components/layouts/header'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { useAuthStore } from '@/stores/auth-store'; +import { useSubscription, usePaymentHistory } from '@/lib/hooks/use-subscription'; +import { CreditCard, Calendar, CheckCircle, AlertCircle, Clock, XCircle } from 'lucide-react'; + +const statusConfig: Record = { + authorized: { label: 'Activa', color: 'text-green-600 bg-green-50', icon: CheckCircle }, + pending: { label: 'Pendiente', color: 'text-yellow-600 bg-yellow-50', icon: Clock }, + paused: { label: 'Pausada', color: 'text-orange-600 bg-orange-50', icon: AlertCircle }, + cancelled: { label: 'Cancelada', color: 'text-red-600 bg-red-50', icon: XCircle }, +}; + +export default function SuscripcionPage() { + const { user } = useAuthStore(); + const { data: subscription, isLoading } = useSubscription(user?.tenantId); + const { data: payments } = usePaymentHistory(user?.tenantId); + + const status = statusConfig[subscription?.status || ''] || statusConfig.pending; + const StatusIcon = status.icon; + + return ( + <> +
+
+ {/* Subscription Status */} + + + + + Estado de Suscripción + + + + {isLoading ? ( +
+
+
+
+ ) : subscription ? ( +
+
+

Plan

+

{subscription.plan}

+
+
+

Estado

+ + + {status.label} + +
+
+

Monto Mensual

+

+ ${Number(subscription.amount).toLocaleString('es-MX')} MXN +

+
+
+ ) : ( +

No se encontró información de suscripción.

+ )} + + + + {/* Payment History */} + + + + + Historial de Pagos + + + + {payments && payments.length > 0 ? ( +
+ + + + + + + + + + + {payments.map((payment) => ( + + + + + + + ))} + +
FechaMontoEstadoMétodo
+ {new Date(payment.createdAt).toLocaleDateString('es-MX')} + + ${Number(payment.amount).toLocaleString('es-MX')} + + + {payment.status === 'approved' ? 'Aprobado' : + payment.status === 'rejected' ? 'Rechazado' : 'Pendiente'} + + + {payment.paymentMethod || '-'} +
+
+ ) : ( +

+ No hay pagos registrados aún. +

+ )} +
+
+
+ + ); +} diff --git a/apps/web/components/layouts/sidebar.tsx b/apps/web/components/layouts/sidebar.tsx index 3f2e81f..23b6f5a 100644 --- a/apps/web/components/layouts/sidebar.tsx +++ b/apps/web/components/layouts/sidebar.tsx @@ -16,23 +16,33 @@ import { Users, Building2, UserCog, + CreditCard, } from 'lucide-react'; import { useAuthStore } from '@/stores/auth-store'; import { logout } from '@/lib/api/auth'; import { useRouter } from 'next/navigation'; +import { hasFeature, type Plan } from '@horux/shared'; -const navigation = [ +interface NavItem { + name: string; + href: string; + icon: typeof LayoutDashboard; + feature?: string; // Required plan feature — hidden if tenant's plan lacks it +} + +const navigation: NavItem[] = [ { name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, { name: 'CFDI', href: '/cfdi', icon: FileText }, { name: 'Impuestos', href: '/impuestos', icon: Calculator }, - { name: 'Reportes', href: '/reportes', icon: BarChart3 }, - { name: 'Calendario', href: '/calendario', icon: Calendar }, - { name: 'Alertas', href: '/alertas', icon: Bell }, + { name: 'Reportes', href: '/reportes', icon: BarChart3, feature: 'reportes' }, + { name: 'Calendario', href: '/calendario', icon: Calendar, feature: 'calendario' }, + { name: 'Alertas', href: '/alertas', icon: Bell, feature: 'alertas' }, { name: 'Usuarios', href: '/usuarios', icon: Users }, + { name: 'Suscripción', href: '/configuracion/suscripcion', icon: CreditCard }, { name: 'Configuracion', href: '/configuracion', icon: Settings }, ]; -const adminNavigation = [ +const adminNavigation: NavItem[] = [ { name: 'Clientes', href: '/clientes', icon: Building2 }, { name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog }, ]; @@ -53,9 +63,15 @@ export function Sidebar() { } }; + // Filter navigation based on tenant plan features + const plan = (user?.plan || 'starter') as Plan; + const filteredNav = navigation.filter( + (item) => !item.feature || hasFeature(plan, item.feature) + ); + const allNavigation = user?.role === 'admin' - ? [...navigation.slice(0, -1), ...adminNavigation, navigation[navigation.length - 1]] - : navigation; + ? [...filteredNav.slice(0, -1), ...adminNavigation, filteredNav[filteredNav.length - 1]] + : filteredNav; return (