Initial commit - Horux Despachos NL

This commit is contained in:
2026-05-03 16:47:53 -06:00
commit b00b677c54
647 changed files with 133843 additions and 0 deletions

View File

@@ -0,0 +1,146 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import { isGlobalAdminRfc } from '@horux/shared';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
import {
CheckCircle,
AlertCircle,
Clock,
XCircle,
Building,
Sparkles,
} from 'lucide-react';
// ============================================================================
// Helpers
// ============================================================================
const statusConfig: Record<string, { label: string; color: string; bgColor: string; icon: typeof CheckCircle }> = {
authorized: { label: 'Activa', color: 'text-green-700', bgColor: 'bg-green-50 border-green-200', icon: CheckCircle },
trial: { label: 'Prueba gratis', color: 'text-blue-700', bgColor: 'bg-blue-50 border-blue-200', icon: Sparkles },
trial_expired: { label: 'Prueba vencida', color: 'text-red-700', bgColor: 'bg-red-50 border-red-200', icon: XCircle },
trial_converted: { label: 'Prueba convertida', color: 'text-green-700', bgColor: 'bg-green-50 border-green-200', icon: CheckCircle },
pending: { label: 'Pendiente de pago', color: 'text-yellow-700', bgColor: 'bg-yellow-50 border-yellow-200', icon: Clock },
paused: { label: 'Pausada', color: 'text-orange-700', bgColor: 'bg-orange-50 border-orange-200', icon: AlertCircle },
cancelled: { label: 'Cancelada', color: 'text-red-700', bgColor: 'bg-red-50 border-red-200', icon: XCircle },
};
// ============================================================================
// Admin global: vista de todas las suscripciones
// ============================================================================
// Edición de precios de planes movida a /configuracion/precios-suscripcion.
// Vista self-serve para clientes movida a /configuracion/planes-despacho.
function AdminGlobalSubscriptions() {
const { data: subscriptions, isLoading } = useQuery({
queryKey: ['all-subscriptions'],
queryFn: () => apiClient.get('/subscriptions').then(r => r.data),
});
if (isLoading) return <div className="text-center py-8 text-muted-foreground">Cargando...</div>;
const subs = (subscriptions || []) as any[];
const activas = subs.filter((s: any) => s.status === 'authorized' || s.status === 'active');
const pendientes = subs.filter((s: any) => s.status === 'pending');
const canceladas = subs.filter((s: any) => s.status === 'cancelled' || s.status === 'paused');
return (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-4">
<Card><CardContent className="pt-6"><div className="flex items-center gap-3"><div className="p-2 bg-primary/10 rounded-lg"><Building className="h-5 w-5 text-primary" /></div><div><p className="text-2xl font-bold">{subs.length}</p><p className="text-xs text-muted-foreground">Total</p></div></div></CardContent></Card>
<Card><CardContent className="pt-6"><div className="flex items-center gap-3"><div className="p-2 bg-green-100 rounded-lg"><CheckCircle className="h-5 w-5 text-green-600" /></div><div><p className="text-2xl font-bold">{activas.length}</p><p className="text-xs text-muted-foreground">Activas</p></div></div></CardContent></Card>
<Card><CardContent className="pt-6"><div className="flex items-center gap-3"><div className="p-2 bg-yellow-100 rounded-lg"><Clock className="h-5 w-5 text-yellow-600" /></div><div><p className="text-2xl font-bold">{pendientes.length}</p><p className="text-xs text-muted-foreground">Pendientes</p></div></div></CardContent></Card>
<Card><CardContent className="pt-6"><div className="flex items-center gap-3"><div className="p-2 bg-red-100 rounded-lg"><XCircle className="h-5 w-5 text-red-600" /></div><div><p className="text-2xl font-bold">{canceladas.length}</p><p className="text-xs text-muted-foreground">Canceladas</p></div></div></CardContent></Card>
</div>
<Card>
<CardHeader><CardTitle className="text-base">Todas las Suscripciones</CardTitle></CardHeader>
<CardContent>
{subs.length === 0 ? (
<p className="text-center py-8 text-muted-foreground">No hay suscripciones</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4">Cliente</th><th className="py-2 pr-4">RFC</th><th className="py-2 pr-4">Plan</th><th className="py-2 pr-4">Estado</th><th className="py-2 pr-4 text-right">Monto</th><th className="py-2 pr-4">Frecuencia</th><th className="py-2 pr-4">Siguiente pago</th><th className="py-2">Creada</th>
</tr>
</thead>
<tbody>
{subs.map((s: any) => {
const st = statusConfig[s.status] || statusConfig.pending;
const StIcon = st.icon;
return (
<tr key={s.id} className="border-b last:border-b-0 hover:bg-muted/50">
<td className="py-3 pr-4 font-medium">{s.tenant?.nombre || '—'}</td>
<td className="py-3 pr-4 font-mono text-xs">{s.tenant?.rfc || '—'}</td>
<td className="py-3 pr-4"><span className="px-2 py-0.5 rounded text-xs font-medium bg-muted capitalize">{s.plan}</span></td>
<td className="py-3 pr-4"><span className={`inline-flex items-center gap-1 text-xs font-medium ${st.color}`}><StIcon className="h-3 w-3" />{st.label}</span></td>
<td className="py-3 pr-4 text-right font-medium">${Number(s.amount).toLocaleString('es-MX', { minimumFractionDigits: 2 })}</td>
<td className="py-3 pr-4 text-muted-foreground capitalize">{s.frequency}</td>
<td className="py-3 pr-4">{s.currentPeriodEnd ? new Date(s.currentPeriodEnd).toLocaleDateString('es-MX') : '—'}</td>
<td className="py-3 text-muted-foreground">{new Date(s.createdAt).toLocaleDateString('es-MX')}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
// ============================================================================
// Frequency Toggle
// ============================================================================
function FrequencyToggle({ value, onChange }: { value: 'monthly' | 'annual'; onChange: (v: 'monthly' | 'annual') => void }) {
return (
<div className="inline-flex items-center rounded-lg border bg-card p-1 text-sm">
<button type="button" onClick={() => onChange('monthly')} className={`px-4 py-1.5 rounded-md font-medium transition-colors ${value === 'monthly' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}>Mensual</button>
<button type="button" onClick={() => onChange('annual')} className={`px-4 py-1.5 rounded-md font-medium transition-colors ${value === 'annual' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}>Anual <span className="ml-1 text-xs opacity-75">(ahorra 17%)</span></button>
</div>
);
}
// ============================================================================
// Main Page
// ============================================================================
export default function SuscripcionPage() {
const { user } = useAuthStore();
const router = useRouter();
const isAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
// Para clientes la gestión de suscripción vive en /configuracion/planes-despacho.
// Esta página queda como panel agregado del admin global (ver TODAS las suscripciones).
// Si por algún link viejo cae un cliente regular, lo enviamos a Planes.
useEffect(() => {
if (!isAdmin) router.replace('/configuracion/planes-despacho');
}, [isAdmin, router]);
if (!isAdmin) {
return (
<div className="min-h-screen flex items-center justify-center text-muted-foreground">
Redirigiendo a Planes
</div>
);
}
// Admin global: vista agregada de todas las suscripciones de la plataforma.
return (
<>
<Header title="Suscripciones" />
<main className="p-6"><AdminGlobalSubscriptions /></main>
</>
);
}