feat: facturación primer pago, fixes SAT/MP, autocompletado RFCs/conceptos

Backend:
- Notificación email al admin cuando llega primer pago aprobado (sin factura auto)
- Endpoints GET /pagos-sin-factura y POST /emitir-factura-pago para admin global
- Fix vinculación org Facturapi Horux 360 (69f23a5a242e0af47a41fa0d)
- Fix webhook MP: validación defensiva de x-signature header
- Fix autocompleto RFCs: eliminado filtro por contribuyenteId
- Fix autocompleto conceptos: eliminado filtro por contribuyenteId
- SAT fixes: anti-bot CSF scraper, request reuse, date range fix, stale job thresholds
- SAT sync request reuse across jobs para evitar agotar cuota diaria
- Typo fix MP_ACCESS_TOKEN en .env
- Trial invitations system backend

Frontend:
- Nueva página /admin/facturas-pendientes con tabla y emisión manual
- Métrica 'Facturas pendientes' en /clientes (clickable)
- Navegación onboarding FIEL/CSD corregida
- Sidebar themes sincronizados
- Fix SAT portal migration scraper (NetIQ)
- Trial invitation acceptance pages
This commit is contained in:
Horux Dev
2026-05-09 21:56:42 +00:00
parent b00b677c54
commit 9f11a0ba39
70 changed files with 2801 additions and 609 deletions

View File

@@ -7,7 +7,7 @@ import Image from 'next/image';
import { Button, Input, Label, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@horux/shared-ui';
import { login } from '@/lib/api/auth';
import { useAuthStore } from '@/stores/auth-store';
import { isGlobalAdminRfc } from '@horux/shared';
import { isGlobalAdminRfc, type PlatformRole } from '@horux/shared';
import { shouldShowOnboarding } from '@/lib/onboarding';
export default function LoginPage() {
@@ -33,7 +33,7 @@ export default function LoginPage() {
const userRole = response.user?.role;
// Admin global aterriza directo en `/clientes` — su home natural es la
// gestión de tenants, no el dashboard operativo del despacho.
const platformRoles = (response.user as { platformRoles?: string[] }).platformRoles;
const platformRoles = (response.user as { platformRoles?: PlatformRole[] }).platformRoles;
const isGlobalAdmin = isGlobalAdminRfc(response.user?.tenantRfc, userRole, platformRoles);
if (isGlobalAdmin) {
router.push('/clientes');

View File

@@ -3,23 +3,18 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Button, Input, Label, Card, CardContent, CardHeader, CardTitle, cn } from '@horux/shared-ui';
import { Button, Input, Label, Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import { apiClient } from '@/lib/api/client';
import { CheckCircle2, Server, Cloud, ArrowLeft, Clock, Building, Sparkles } from 'lucide-react';
import { CheckCircle2, ArrowLeft } from 'lucide-react';
type VerticalProfile = 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
type PlanType = 'trial' | 'mi_empresa' | 'mi_empresa_plus' | 'business_control' | 'business_cloud';
export default function RegisterDespachoPage() {
const router = useRouter();
const { setUser, setTokens } = useAuthStore();
const [step, setStep] = useState(1);
const [verticalProfile, setVerticalProfile] = useState<VerticalProfile | null>(null);
const [selectedPlan, setSelectedPlan] = useState<PlanType | null>(null);
// Default 'annual' — sesgo intencional al cash-flow del negocio (10 meses
// = 17% descuento para el cliente, año completo cobrado upfront para nosotros).
const [billingFrequency, setBillingFrequency] = useState<'monthly' | 'annual'>('annual');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [form, setForm] = useState({
@@ -37,7 +32,7 @@ export default function RegisterDespachoPage() {
const handleSubmit = async () => {
if (!form.acceptedTerms) { setError('Debes aceptar los términos y condiciones'); return; }
if (!verticalProfile || !selectedPlan) { setError('Completa todos los pasos'); return; }
if (!verticalProfile) { setError('Selecciona un tipo de despacho'); return; }
setLoading(true);
setError('');
try {
@@ -45,10 +40,6 @@ export default function RegisterDespachoPage() {
despacho: {
nombre: form.despachoNombre,
verticalProfile,
plan: selectedPlan,
// Solo mi_empresa(+) acepta monthly; el backend ignora frequency
// para los demás planes. Mandamos siempre el state para coherencia.
frequency: billingFrequency,
},
owner: {
nombre: form.ownerNombre,
@@ -58,13 +49,7 @@ export default function RegisterDespachoPage() {
});
setTokens(data.accessToken, data.refreshToken);
setUser(data.user);
// If paid plan with payment URL, redirect to MercadoPago
if (data.paymentUrl) {
window.location.href = data.paymentUrl;
} else {
router.push('/onboarding');
}
router.push('/onboarding');
} catch (err: any) {
setError(err.response?.data?.message || 'Error al registrar el despacho');
setStep(1);
@@ -85,8 +70,6 @@ export default function RegisterDespachoPage() {
<span className="bg-primary text-primary-foreground rounded-full w-6 h-6 flex items-center justify-center font-bold">1</span>
<span className="w-8 h-px bg-muted" />
<span className="bg-muted text-muted-foreground rounded-full w-6 h-6 flex items-center justify-center">2</span>
<span className="w-8 h-px bg-muted" />
<span className="bg-muted text-muted-foreground rounded-full w-6 h-6 flex items-center justify-center">3</span>
</div>
<CardTitle className="text-2xl font-bold">Crea tu cuenta</CardTitle>
<p className="text-sm text-muted-foreground mt-1">Plataforma para despachos profesionales</p>
@@ -118,297 +101,43 @@ export default function RegisterDespachoPage() {
}
// =================== STEP 2: Vertical Selection ===================
if (step === 2) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-purple-50 dark:from-gray-900 dark:to-gray-800 p-4">
<div className="w-full max-w-3xl space-y-8 text-center">
<div>
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground mb-4">
<span className="bg-green-500 text-white rounded-full w-6 h-6 flex items-center justify-center"><CheckCircle2 className="h-4 w-4" /></span>
<span className="w-8 h-px bg-primary" />
<span className="bg-primary text-primary-foreground rounded-full w-6 h-6 flex items-center justify-center font-bold">2</span>
<span className="w-8 h-px bg-muted" />
<span className="bg-muted text-muted-foreground rounded-full w-6 h-6 flex items-center justify-center">3</span>
</div>
<h1 className="text-3xl font-bold">¿Qué tipo de despacho eres?</h1>
<p className="text-muted-foreground mt-2">Selecciona tu área profesional</p>
</div>
<div className="grid md:grid-cols-3 gap-4">
<button
onClick={() => { setVerticalProfile('CONTABLE'); setStep(3); }}
className="p-8 rounded-xl border-2 border-primary bg-card hover:bg-accent transition-all text-center space-y-3"
>
<div className="text-4xl">📊</div>
<h3 className="text-lg font-semibold">Contable</h3>
<p className="text-sm text-muted-foreground">Gestión fiscal, CFDI, IVA/ISR, SAT sync</p>
</button>
<div className="p-8 rounded-xl border-2 border-dashed border-muted bg-muted/30 text-center space-y-3 opacity-50 cursor-not-allowed">
<div className="text-4xl"></div>
<h3 className="text-lg font-semibold">Jurídico</h3>
<p className="text-sm text-muted-foreground">Próximamente</p>
</div>
<div className="p-8 rounded-xl border-2 border-dashed border-muted bg-muted/30 text-center space-y-3 opacity-50 cursor-not-allowed">
<div className="text-4xl">🏗</div>
<h3 className="text-lg font-semibold">Arquitectura</h3>
<p className="text-sm text-muted-foreground">Próximamente</p>
</div>
</div>
<button onClick={() => setStep(1)} className="text-sm text-muted-foreground underline">
<ArrowLeft className="h-3 w-3 inline mr-1" />Volver al formulario
</button>
</div>
</div>
);
}
// =================== STEP 3: Subscription Selection ===================
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-purple-50 dark:from-gray-900 dark:to-gray-800 py-8 px-4">
<div className="w-full max-w-7xl space-y-8">
<div className="text-center">
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-purple-50 dark:from-gray-900 dark:to-gray-800 p-4">
<div className="w-full max-w-3xl space-y-8 text-center">
<div>
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground mb-4">
<span className="bg-green-500 text-white rounded-full w-6 h-6 flex items-center justify-center"><CheckCircle2 className="h-4 w-4" /></span>
<span className="w-8 h-px bg-green-500" />
<span className="bg-green-500 text-white rounded-full w-6 h-6 flex items-center justify-center"><CheckCircle2 className="h-4 w-4" /></span>
<span className="w-8 h-px bg-primary" />
<span className="bg-primary text-primary-foreground rounded-full w-6 h-6 flex items-center justify-center font-bold">3</span>
<span className="bg-primary text-primary-foreground rounded-full w-6 h-6 flex items-center justify-center font-bold">2</span>
</div>
<h1 className="text-3xl font-bold">Elige tu plan</h1>
<p className="text-muted-foreground mt-2">Empieza con el trial gratuito de 30 días o contrata un plan directo.</p>
<h1 className="text-3xl font-bold">¿Qué tipo de despacho eres?</h1>
<p className="text-muted-foreground mt-2">Selecciona tu área profesional</p>
</div>
{/* Toggle facturación mensual / anual (afecta solo Mi Empresa y Mi Empresa+) */}
<div className="flex justify-center">
<div className="inline-flex items-center gap-1 rounded-full border bg-muted/30 p-1">
<button
type="button"
onClick={() => setBillingFrequency('monthly')}
className={cn(
'px-4 py-1.5 rounded-full text-sm font-medium transition-colors',
billingFrequency === 'monthly'
? 'bg-background shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
Mensual
</button>
<button
type="button"
onClick={() => setBillingFrequency('annual')}
className={cn(
'px-4 py-1.5 rounded-full text-sm font-medium transition-colors flex items-center gap-2',
billingFrequency === 'annual'
? 'bg-background shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
Anual
<span className={cn(
'text-[10px] px-1.5 py-0.5 rounded-full',
billingFrequency === 'annual'
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400'
: 'bg-muted text-muted-foreground'
)}>
Ahorra 17%
</span>
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
{/* Trial Gratuito */}
<Card
className={cn(
'cursor-pointer transition-all hover:shadow-lg',
selectedPlan === 'trial' && 'border-primary ring-2 ring-primary/20'
)}
onClick={() => setSelectedPlan('trial')}
<div className="grid md:grid-cols-3 gap-4">
<button
onClick={() => { setVerticalProfile('CONTABLE'); handleSubmit(); }}
disabled={loading}
className="p-8 rounded-xl border-2 border-primary bg-card hover:bg-accent transition-all text-center space-y-3"
>
<CardHeader className="text-center pb-2">
<div className="mx-auto bg-green-100 dark:bg-green-900 rounded-full p-3 w-fit mb-2">
<Clock className="h-6 w-6 text-green-600 dark:text-green-400" />
</div>
<CardTitle className="text-lg">Trial Gratuito</CardTitle>
<p className="text-xs text-muted-foreground">Prueba sin compromiso</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-center">
<div className="text-2xl font-bold">$0</div>
<p className="text-xs text-muted-foreground">30 días</p>
<p className="text-xs text-muted-foreground mt-1">Sin tarjeta</p>
</div>
<div className="space-y-1.5 text-xs">
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Hasta 3 RFCs</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>1 usuario</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Todas las funcionalidades</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>BD en la nube</span></div>
</div>
</CardContent>
</Card>
{/* Mi Empresa */}
<Card
className={cn(
'cursor-pointer transition-all hover:shadow-lg',
selectedPlan === 'mi_empresa' && 'border-primary ring-2 ring-primary/20'
)}
onClick={() => setSelectedPlan('mi_empresa')}
>
<CardHeader className="text-center pb-2">
<div className="mx-auto bg-orange-100 dark:bg-orange-900 rounded-full p-3 w-fit mb-2">
<Building className="h-6 w-6 text-orange-600 dark:text-orange-400" />
</div>
<CardTitle className="text-lg">Mi Empresa</CardTitle>
<p className="text-xs text-muted-foreground">Para 1 contribuyente</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-center">
{billingFrequency === 'annual' ? (
<>
<div className="text-2xl font-bold">$5,800</div>
<p className="text-xs text-muted-foreground">por año</p>
<p className="text-xs text-green-600 dark:text-green-400 mt-1 font-medium">Equivale a 10 meses</p>
</>
) : (
<>
<div className="text-2xl font-bold">$580</div>
<p className="text-xs text-muted-foreground">mensual</p>
<p className="text-xs text-muted-foreground mt-1">o $5,800/año (10 meses)</p>
</>
)}
</div>
<div className="space-y-1.5 text-xs">
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>1 RFC</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>3 usuarios</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>50 timbres/mes</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>BD en la nube</span></div>
</div>
</CardContent>
</Card>
{/* Mi Empresa + */}
<Card
className={cn(
'cursor-pointer transition-all hover:shadow-lg',
selectedPlan === 'mi_empresa_plus' && 'border-primary ring-2 ring-primary/20'
)}
onClick={() => setSelectedPlan('mi_empresa_plus')}
>
<CardHeader className="text-center pb-2">
<div className="mx-auto bg-purple-100 dark:bg-purple-900 rounded-full p-3 w-fit mb-2">
<Sparkles className="h-6 w-6 text-purple-600 dark:text-purple-400" />
</div>
<CardTitle className="text-lg">Mi Empresa +</CardTitle>
<p className="text-xs text-muted-foreground">Con IA + API</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-center">
{billingFrequency === 'annual' ? (
<>
<div className="text-2xl font-bold">$9,000</div>
<p className="text-xs text-muted-foreground">por año</p>
<p className="text-xs text-green-600 dark:text-green-400 mt-1 font-medium">Equivale a 10 meses</p>
</>
) : (
<>
<div className="text-2xl font-bold">$900</div>
<p className="text-xs text-muted-foreground">mensual</p>
<p className="text-xs text-muted-foreground mt-1">o $9,000/año (10 meses)</p>
</>
)}
</div>
<div className="space-y-1.5 text-xs">
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Todo Mi Empresa</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Lolita IA Fiscal</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>API de integración</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>SAT incremental</span></div>
</div>
</CardContent>
</Card>
{/* Business Control */}
<Card
className={cn(
'cursor-pointer transition-all hover:shadow-lg relative',
selectedPlan === 'business_control' && 'border-primary ring-2 ring-primary/20'
)}
onClick={() => setSelectedPlan('business_control')}
>
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary text-primary-foreground text-xs px-3 py-1 rounded-full">
Más popular
</div>
<CardHeader className="text-center pb-2">
<div className="mx-auto bg-blue-100 dark:bg-blue-900 rounded-full p-3 w-fit mb-2">
<Server className="h-6 w-6 text-blue-600 dark:text-blue-400" />
</div>
<CardTitle className="text-lg">Business Control</CardTitle>
<p className="text-xs text-muted-foreground">Despachos contables</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-center">
<div className="text-2xl font-bold">$25,850</div>
<p className="text-xs text-muted-foreground">por año (IVA inc.)</p>
<p className="text-xs text-muted-foreground mt-1">+ $45/mes por RFC extra</p>
</div>
<div className="space-y-1.5 text-xs">
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>100 RFCs incluidos</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Usuarios ilimitados</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>BD en tu servidor</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Servidor backup</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>API de integración</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>SAT incremental</span></div>
</div>
</CardContent>
</Card>
{/* Enterprise (business_cloud) */}
<Card
className={cn(
'cursor-pointer transition-all hover:shadow-lg',
selectedPlan === 'business_cloud' && 'border-primary ring-2 ring-primary/20'
)}
onClick={() => setSelectedPlan('business_cloud')}
>
<CardHeader className="text-center pb-2">
<div className="mx-auto bg-amber-100 dark:bg-amber-900 rounded-full p-3 w-fit mb-2">
<Cloud className="h-6 w-6 text-amber-600 dark:text-amber-400" />
</div>
<CardTitle className="text-lg">Enterprise</CardTitle>
<p className="text-xs text-muted-foreground">Despachos de alto volumen</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-center">
<div className="text-2xl font-bold">$43,000</div>
<p className="text-xs text-muted-foreground">por año (IVA inc.)</p>
<p className="text-xs text-muted-foreground mt-1">+ $45/mes por RFC extra</p>
</div>
<div className="space-y-1.5 text-xs">
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>100 RFCs incluidos</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>3M CFDIs por contribuyente</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Usuarios ilimitados</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>BD en tu servidor</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Servidor backup</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>SAT incremental + API</span></div>
</div>
</CardContent>
</Card>
</div>
{error && <p className="text-sm text-destructive bg-destructive/10 p-3 rounded-md text-center max-w-lg mx-auto">{error}</p>}
<div className="flex flex-col items-center gap-3">
<Button
onClick={handleSubmit}
disabled={!selectedPlan || loading}
size="lg"
className="px-12"
>
{loading ? 'Creando tu despacho...' : selectedPlan === 'trial' ? 'Comenzar trial gratuito' : 'Continuar al pago'}
</Button>
<button onClick={() => setStep(2)} className="text-sm text-muted-foreground underline">
<ArrowLeft className="h-3 w-3 inline mr-1" />Volver
<div className="text-4xl">📊</div>
<h3 className="text-lg font-semibold">Contable</h3>
<p className="text-sm text-muted-foreground">Gestión fiscal, CFDI, IVA/ISR, SAT sync</p>
</button>
<div className="p-8 rounded-xl border-2 border-dashed border-muted bg-muted/30 text-center space-y-3 opacity-50 cursor-not-allowed">
<div className="text-4xl"></div>
<h3 className="text-lg font-semibold">Jurídico</h3>
<p className="text-sm text-muted-foreground">Próximamente</p>
</div>
<div className="p-8 rounded-xl border-2 border-dashed border-muted bg-muted/30 text-center space-y-3 opacity-50 cursor-not-allowed">
<div className="text-4xl">🏗</div>
<h3 className="text-lg font-semibold">Arquitectura</h3>
<p className="text-sm text-muted-foreground">Próximamente</p>
</div>
</div>
{error && <p className="text-sm text-destructive bg-destructive/10 p-3 rounded-md max-w-lg mx-auto">{error}</p>}
<button onClick={() => setStep(1)} className="text-sm text-muted-foreground underline">
<ArrowLeft className="h-3 w-3 inline mr-1" />Volver al formulario
</button>
</div>
</div>
);

View File

@@ -34,9 +34,9 @@ function ResetPasswordContent() {
</CardDescription>
</CardHeader>
<CardFooter>
<Link href="/forgot-password" className="w-full">
<Button className="w-full">Solicitar nuevo enlace</Button>
</Link>
<Button className="w-full" asChild>
<Link href="/forgot-password">Solicitar nuevo enlace</Link>
</Button>
</CardFooter>
</Card>
);
@@ -82,9 +82,9 @@ function ResetPasswordContent() {
</CardDescription>
</CardHeader>
<CardFooter>
<Link href="/login" className="w-full">
<Button className="w-full">Ir al login</Button>
</Link>
<Button className="w-full" asChild>
<Link href="/login">Ir al login</Link>
</Button>
</CardFooter>
</Card>
);

View File

@@ -0,0 +1,169 @@
'use client';
import { useState } from 'react';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, Button } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import { isGlobalAdminRfc } from '@horux/shared';
import { usePagosSinFactura, useEmitirFacturaPago } from '@/lib/hooks/use-pagos-sin-factura';
import { ShieldAlert, FileText, RefreshCw, CheckCircle, AlertCircle, Receipt } from 'lucide-react';
const PLAN_LABELS: Record<string, string> = {
trial: 'Trial',
custom: 'Custom',
mi_empresa: 'Mi Empresa',
mi_empresa_plus: 'Mi Empresa Plus',
business_control: 'Business Control',
business_cloud: 'Enterprise',
};
const METHOD_LABELS: Record<string, string> = {
master: 'Mastercard',
visa: 'Visa',
amex: 'Amex',
debmaster: 'Débito Mastercard',
debvisa: 'Débito Visa',
account_money: 'MercadoPago',
bank_transfer: 'Transferencia',
};
function formatCurrency(amount: string | number): string {
const num = typeof amount === 'string' ? parseFloat(amount) : amount;
return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(num);
}
function formatDate(iso: string | null): string {
if (!iso) return '—';
return new Date(iso).toLocaleString('es-MX', { dateStyle: 'short', timeStyle: 'short' });
}
export default function FacturasPendientesPage() {
const { user } = useAuthStore();
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
const { data: payments, isLoading, error } = usePagosSinFactura();
const emitir = useEmitirFacturaPago();
const [emitiendoId, setEmitiendoId] = useState<string | null>(null);
if (!isGlobalAdmin) {
return (
<>
<Header title="Facturas Pendientes" />
<main className="p-6">
<Card>
<CardContent className="py-12 text-center">
<ShieldAlert className="h-12 w-12 text-muted-foreground/40 mx-auto mb-3" />
<p className="font-semibold">Acceso restringido</p>
<p className="text-sm text-muted-foreground mt-1">
Solo el administrador global puede consultar pagos sin factura.
</p>
</CardContent>
</Card>
</main>
</>
);
}
async function handleEmitir(paymentId: string) {
setEmitiendoId(paymentId);
try {
await emitir.mutateAsync(paymentId);
} finally {
setEmitiendoId(null);
}
}
return (
<>
<Header title="Facturas Pendientes" />
<main className="p-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Receipt className="h-5 w-5" />
Pagos de suscripción sin factura
</CardTitle>
</CardHeader>
<CardContent>
{isLoading && (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<RefreshCw className="h-5 w-5 animate-spin mr-2" />
Cargando...
</div>
)}
{error && (
<div className="flex items-center justify-center py-12 text-red-600">
<AlertCircle className="h-5 w-5 mr-2" />
Error al cargar los pagos
</div>
)}
{!isLoading && !error && payments && payments.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<CheckCircle className="h-10 w-10 mb-3 text-green-500" />
<p className="font-medium">No hay pagos pendientes de facturar</p>
<p className="text-sm mt-1">Todos los pagos aprobados ya tienen su factura emitida.</p>
</div>
)}
{!isLoading && !error && payments && payments.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-3 px-2 font-medium text-muted-foreground">Cliente</th>
<th className="text-left py-3 px-2 font-medium text-muted-foreground">Plan</th>
<th className="text-left py-3 px-2 font-medium text-muted-foreground">Monto</th>
<th className="text-left py-3 px-2 font-medium text-muted-foreground">Método</th>
<th className="text-left py-3 px-2 font-medium text-muted-foreground">Fecha de pago</th>
<th className="text-right py-3 px-2 font-medium text-muted-foreground">Acción</th>
</tr>
</thead>
<tbody>
{payments.map((p) => (
<tr key={p.id} className="border-b last:border-0 hover:bg-muted/30">
<td className="py-3 px-2">
<div className="font-medium">{p.tenant.nombre}</div>
<div className="text-xs text-muted-foreground font-mono">{p.tenant.rfc || '—'}</div>
</td>
<td className="py-3 px-2">
<span className="inline-flex items-center rounded-md bg-secondary px-2 py-1 text-xs font-medium text-secondary-foreground ring-1 ring-inset ring-secondary/20">
{PLAN_LABELS[p.subscription?.plan || 'custom'] || p.subscription?.plan || 'Custom'}
</span>
</td>
<td className="py-3 px-2 font-semibold">{formatCurrency(p.amount)}</td>
<td className="py-3 px-2 text-muted-foreground">
{METHOD_LABELS[p.paymentMethod || ''] || p.paymentMethod || '—'}
</td>
<td className="py-3 px-2 text-muted-foreground">{formatDate(p.paidAt)}</td>
<td className="py-3 px-2 text-right">
<Button
size="sm"
onClick={() => handleEmitir(p.id)}
disabled={emitir.isPending && emitiendoId === p.id}
>
{emitir.isPending && emitiendoId === p.id ? (
<>
<RefreshCw className="h-4 w-4 mr-1 animate-spin" />
Emitiendo...
</>
) : (
<>
<FileText className="h-4 w-4 mr-1" />
Emitir factura
</>
)}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</main>
</>
);
}

View File

@@ -0,0 +1,260 @@
'use client';
import { useEffect, useState } from 'react';
import { Button, Input, Label, Card, CardContent, CardHeader, CardTitle, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui';
import { getAllInvitations, createInvitation, cancelInvitation } from '@/lib/api/trial-invitations';
import { getTenants } from '@/lib/api/tenants';
import { Gift, X, Clock, CheckCircle2, AlertTriangle, Loader2 } from 'lucide-react';
interface TenantOption {
id: string;
nombre: string;
rfc: string;
}
interface Invitation {
id: string;
tenantId: string;
plan: string;
durationDays: number;
status: string;
token: string;
sentAt: string;
expiresAt: string;
acceptedAt: string | null;
tenant: {
nombre: string;
rfc: string;
} | null;
}
export default function InvitacionesTrialPage() {
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [invitations, setInvitations] = useState<Invitation[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [selectedTenantId, setSelectedTenantId] = useState('');
const [durationDays, setDurationDays] = useState('30');
const [plan, setPlan] = useState('business_control');
const [message, setMessage] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
useEffect(() => {
loadData();
}, []);
async function loadData() {
setLoading(true);
try {
const [tenantsData, invitationsData] = await Promise.all([
getTenants(),
getAllInvitations(),
]);
setTenants(tenantsData);
setInvitations(invitationsData);
} catch (err: any) {
setMessage({ kind: 'err', text: err?.response?.data?.message || 'Error al cargar datos' });
} finally {
setLoading(false);
}
}
async function handleCreate() {
if (!selectedTenantId || !durationDays) {
setMessage({ kind: 'err', text: 'Selecciona un despacho y duración' });
return;
}
setCreating(true);
setMessage(null);
try {
await createInvitation({
tenantId: selectedTenantId,
plan,
durationDays: parseInt(durationDays, 10),
});
setMessage({ kind: 'ok', text: 'Invitación enviada correctamente' });
setSelectedTenantId('');
setDurationDays('30');
loadData();
} catch (err: any) {
setMessage({ kind: 'err', text: err?.response?.data?.message || 'Error al crear invitación' });
} finally {
setCreating(false);
}
}
async function handleCancel(id: string) {
if (!confirm('¿Seguro que quieres cancelar esta invitación?')) return;
try {
await cancelInvitation(id);
setMessage({ kind: 'ok', text: 'Invitación cancelada' });
loadData();
} catch (err: any) {
setMessage({ kind: 'err', text: err?.response?.data?.message || 'Error al cancelar' });
}
}
function statusIcon(status: string) {
switch (status) {
case 'pending': return <Clock className="h-4 w-4 text-amber-500" />;
case 'accepted': return <CheckCircle2 className="h-4 w-4 text-green-500" />;
case 'expired': return <AlertTriangle className="h-4 w-4 text-red-500" />;
case 'cancelled': return <X className="h-4 w-4 text-gray-500" />;
default: return null;
}
}
function statusLabel(status: string) {
switch (status) {
case 'pending': return 'Pendiente';
case 'accepted': return 'Aceptada';
case 'expired': return 'Expirada';
case 'cancelled': return 'Cancelada';
default: return status;
}
}
return (
<div className="p-6 max-w-6xl mx-auto space-y-8">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Gift className="h-6 w-6" />
Invitaciones de Trial
</h1>
<p className="text-muted-foreground">Envía invitaciones de prueba a despachos específicos</p>
</div>
{/* Toast de resultado */}
{message && (
<div
className={`max-w-3xl rounded-lg px-4 py-3 text-sm ${
message.kind === 'ok'
? 'bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-300'
: 'bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-300'
}`}
>
{message.text}
</div>
)}
{/* Formulario de creación */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Nueva invitación</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Despacho</Label>
<Select value={selectedTenantId} onValueChange={setSelectedTenantId}>
<SelectTrigger>
<SelectValue placeholder="Selecciona un despacho" />
</SelectTrigger>
<SelectContent>
{tenants.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.nombre} ({t.rfc})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Plan</Label>
<Select value={plan} onValueChange={setPlan}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="business_control">Business Control</SelectItem>
<SelectItem value="business_cloud">Enterprise</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Duración (días)</Label>
<Input
type="number"
min={1}
max={365}
value={durationDays}
onChange={(e) => setDurationDays(e.target.value)}
/>
</div>
</div>
<Button onClick={handleCreate} disabled={creating}>
{creating ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Gift className="h-4 w-4 mr-2" />}
Enviar invitación
</Button>
</CardContent>
</Card>
{/* Tabla de invitaciones */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Historial de invitaciones</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
) : invitations.length === 0 ? (
<p className="text-muted-foreground text-center py-8">No hay invitaciones enviadas</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-3">Despacho</th>
<th className="text-left py-2 px-3">Plan</th>
<th className="text-left py-2 px-3">Días</th>
<th className="text-left py-2 px-3">Estado</th>
<th className="text-left py-2 px-3">Enviado</th>
<th className="text-left py-2 px-3">Expira</th>
<th className="text-left py-2 px-3"></th>
</tr>
</thead>
<tbody>
{invitations.map((inv) => (
<tr key={inv.id} className="border-b hover:bg-muted/50">
<td className="py-2 px-3">
<div className="font-medium">{inv.tenant?.nombre || '—'}</div>
<div className="text-xs text-muted-foreground">{inv.tenant?.rfc || '—'}</div>
</td>
<td className="py-2 px-3">
{inv.plan === 'business_control' ? 'Business Control' : inv.plan === 'business_cloud' ? 'Enterprise' : inv.plan}
</td>
<td className="py-2 px-3">{inv.durationDays}</td>
<td className="py-2 px-3">
<span className="flex items-center gap-1">
{statusIcon(inv.status)}
{statusLabel(inv.status)}
</span>
</td>
<td className="py-2 px-3">
{new Date(inv.sentAt).toLocaleDateString('es-MX')}
</td>
<td className="py-2 px-3">
{new Date(inv.expiresAt).toLocaleDateString('es-MX')}
</td>
<td className="py-2 px-3">
{inv.status === 'pending' && (
<button
onClick={() => handleCancel(inv.id)}
className="text-destructive hover:underline text-xs"
>
Cancelar
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select,
import { useAllUsuarios, useUpdateUsuarioGlobal, useDeleteUsuarioGlobal } from '@/lib/hooks/use-usuarios';
import { getTenants, type Tenant } from '@/lib/api/tenants';
import { useAuthStore } from '@/stores/auth-store';
import { isGlobalAdminRfc } from '@horux/shared';
import { Users, Pencil, Trash2, Shield, Eye, Calculator, Building2, X, Check, UserCog, UserCheck, User, Briefcase } from 'lucide-react';
import { cn } from '@horux/shared-ui';
@@ -43,9 +44,12 @@ export default function AdminUsuariosPage() {
const [filterTenant, setFilterTenant] = useState<string>('all');
const [searchTerm, setSearchTerm] = useState('');
const isGlobal = isGlobalAdminRfc(currentUser?.tenantRfc, currentUser?.role, currentUser?.platformRoles);
useEffect(() => {
if (!isGlobal) return;
getTenants().then(setTenants).catch(console.error);
}, []);
}, [isGlobal]);
const handleEdit = (usuario: any) => {
setEditingUser({

View File

@@ -359,7 +359,7 @@ export default function CfdiPage() {
// CFDI Viewer state
const [viewingCfdi, setViewingCfdi] = useState<Cfdi | null>(null);
const [loadingCfdi, setLoadingCfdi] = useState<string | null>(null);
const [loadingCfdi, setLoadingCfdi] = useState<number | null>(null);
// Cancelación Facturapi state
const [cancelTarget, setCancelTarget] = useState<any | null>(null);
@@ -367,10 +367,10 @@ export default function CfdiPage() {
const [cancelSubstitution, setCancelSubstitution] = useState('');
const [cancelling, setCancelling] = useState(false);
const handleViewCfdi = async (id: string) => {
const handleViewCfdi = async (id: number) => {
setLoadingCfdi(id);
try {
const cfdi = await getCfdiById(id);
const cfdi = await getCfdiById(String(id));
setViewingCfdi(cfdi);
} catch (error) {
console.error('Error loading CFDI:', error);
@@ -882,10 +882,10 @@ export default function CfdiPage() {
setUploadProgress(prev => ({ ...prev, status: 'idle' }));
};
const handleDelete = async (id: string) => {
const handleDelete = async (id: number) => {
if (confirm('¿Eliminar este CFDI?')) {
try {
await deleteCfdi.mutateAsync(id);
await deleteCfdi.mutateAsync(String(id));
} catch (error) {
console.error('Error deleting CFDI:', error);
}
@@ -920,9 +920,9 @@ export default function CfdiPage() {
const calculateTotal = () => {
const subtotal = formData.subtotal || 0;
const descuento = formData.descuento || 0;
const iva = formData.ivaTrasladoTraslado || 0;
const iva = formData.ivaTraslado || 0;
const isrRetencion = formData.isrRetencion || 0;
const ivaRetencion = formData.ivaTrasladoRetencion || 0;
const ivaRetencion = formData.ivaRetencion || 0;
return subtotal - descuento + iva - isrRetencion - ivaRetencion;
};

View File

@@ -6,9 +6,10 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, CardDescription, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui';
import { useTenants, useCreateTenant, useUpdateTenant, useDeleteTenant } from '@/lib/hooks/use-tenants';
import { usePagosSinFactura } from '@/lib/hooks/use-pagos-sin-factura';
import { useTenantViewStore } from '@/stores/tenant-view-store';
import { useAuthStore } from '@/stores/auth-store';
import { Building, Plus, Users, Eye, Calendar, Pencil, Trash2, X, DollarSign, AlertCircle, ChevronRight } from 'lucide-react';
import { Building, Plus, Users, Eye, Calendar, Pencil, Trash2, X, DollarSign, AlertCircle, ChevronRight, Receipt } from 'lucide-react';
import type { Tenant } from '@/lib/api/tenants';
import { isGlobalAdminRfc } from '@horux/shared';
import { getClientesStats, getTenantUsuarios, type TenantUsuario } from '@/lib/api/admin-clientes';
@@ -56,6 +57,7 @@ export default function ClientesPage() {
queryFn: () => getClientesStats(from, to),
enabled: !!user,
});
const { data: pagosSinFactura } = usePagosSinFactura();
// Map tenantId → activeUsers para lookup O(1) cuando renderizamos la lista.
const usuariosPorTenant = useMemo(() => {
@@ -129,14 +131,17 @@ export default function ClientesPage() {
const handleEdit = (tenant: Tenant) => {
setEditingTenant(tenant);
const sub = tenant.subscriptions?.[0];
setFormData({
nombre: tenant.nombre,
rfc: tenant.rfc,
plan: tenant.plan as PlanType,
adminEmail: '',
adminNombre: '',
amount: 0,
firstPaymentDueAt: '',
amount: sub ? Number(sub.amount) : 0,
firstPaymentDueAt: sub?.currentPeriodEnd
? new Date(sub.currentPeriodEnd).toISOString().slice(0, 10)
: '',
});
setShowForm(true);
};
@@ -210,7 +215,7 @@ export default function ClientesPage() {
</Card>
{/* KPIs */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
{/* Total clientes activos */}
<Card>
<CardContent className="pt-6">
@@ -286,6 +291,24 @@ export default function ClientesPage() {
</div>
</CardContent>
</Card>
{/* Facturas pendientes */}
<Card
className="cursor-pointer hover:shadow-md transition-shadow"
onClick={() => router.push('/admin/facturas-pendientes')}
>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="p-3 bg-orange-100 dark:bg-orange-900/30 rounded-lg">
<Receipt className="h-6 w-6 text-orange-600" />
</div>
<div>
<p className="text-2xl font-bold">{pagosSinFactura?.length ?? 0}</p>
<p className="text-sm text-muted-foreground">Facturas pendientes</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Detalle de no renovaciones */}
@@ -454,78 +477,78 @@ export default function ClientesPage() {
</Select>
</div>
{/* Campos de admin y suscripción — solo al crear */}
{/* Campos de admin — solo al crear */}
{!editingTenant && (
<>
<div className="border-t pt-4">
<p className="text-sm font-medium text-muted-foreground mb-3">Dueño del Cliente</p>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="adminNombre">Nombre del Dueño</Label>
<Input
id="adminNombre"
value={formData.adminNombre}
onChange={(e) => setFormData({ ...formData, adminNombre: e.target.value })}
placeholder="Juan Pérez"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="adminEmail">Email del Dueño</Label>
<Input
id="adminEmail"
type="email"
value={formData.adminEmail}
onChange={(e) => setFormData({ ...formData, adminEmail: e.target.value })}
placeholder="admin@empresa.com"
required
/>
</div>
<div className="border-t pt-4">
<p className="text-sm font-medium text-muted-foreground mb-3">Dueño del Cliente</p>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="adminNombre">Nombre del Dueño</Label>
<Input
id="adminNombre"
value={formData.adminNombre}
onChange={(e) => setFormData({ ...formData, adminNombre: e.target.value })}
placeholder="Juan Pérez"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="adminEmail">Email del Dueño</Label>
<Input
id="adminEmail"
type="email"
value={formData.adminEmail}
onChange={(e) => setFormData({ ...formData, adminEmail: e.target.value })}
placeholder="admin@empresa.com"
required
/>
</div>
</div>
{formData.plan !== 'trial' && (
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="amount">Monto Mensual (MXN)</Label>
<Input
id="amount"
type="number"
min="0"
step="0.01"
value={formData.amount || ''}
onChange={(e) => setFormData({ ...formData, amount: parseFloat(e.target.value) || 0 })}
placeholder="0.00"
/>
{formData.plan === 'custom' && (
<p className="text-xs text-muted-foreground">
Custom: monto variable. Si dejas $0, no se cobra ni se solicita tarjeta.
Si pones &gt;$0, se generará Subscription con preapproval MercadoPago mensual.
</p>
)}
</div>
{formData.plan === 'custom' && (
<div className="space-y-2">
<Label htmlFor="firstPaymentDueAt">Primera fecha de pago</Label>
<Input
id="firstPaymentDueAt"
type="date"
min={new Date().toISOString().slice(0, 10)}
value={formData.firstPaymentDueAt}
onChange={(e) => setFormData({ ...formData, firstPaymentDueAt: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
Deadline visible al cliente para realizar su primer pago. Opcional.
</p>
</div>
)}
</div>
)}
{/* Campos de suscripción — crear y editar (solo planes pagados / custom) */}
{formData.plan !== 'trial' && (
<div className="grid gap-4 md:grid-cols-2 border-t pt-4">
<div className="space-y-2">
<Label htmlFor="amount">Monto Mensual (MXN)</Label>
<Input
id="amount"
type="number"
min="0"
step="0.01"
value={formData.amount || ''}
onChange={(e) => setFormData({ ...formData, amount: parseFloat(e.target.value) || 0 })}
placeholder="0.00"
/>
{formData.plan === 'custom' && (
<p className="text-xs text-muted-foreground">
Custom: monto variable. Si dejas $0, no se cobra ni se solicita tarjeta.
Si pones &gt;$0, se generará Subscription con preapproval MercadoPago mensual.
</p>
)}
</div>
{formData.plan === 'custom' && (
<div className="space-y-2">
<Label htmlFor="firstPaymentDueAt">Primera fecha de pago</Label>
<Input
id="firstPaymentDueAt"
type="date"
min={new Date().toISOString().slice(0, 10)}
value={formData.firstPaymentDueAt}
onChange={(e) => setFormData({ ...formData, firstPaymentDueAt: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
Deadline visible al cliente para realizar su primer pago. Opcional.
</p>
</div>
)}
{formData.plan === 'trial' && (
<p className="text-xs text-muted-foreground">
Plan de prueba: 30 días gratis sin tarjeta. Se convierte a un plan pagado al final del periodo.
</p>
)}
</>
</div>
)}
{formData.plan === 'trial' && (
<p className="text-xs text-muted-foreground">
Plan de prueba: 30 días gratis sin tarjeta. Se convierte a un plan pagado al final del periodo.
</p>
)}
<div className="flex gap-2 justify-end">

View File

@@ -77,7 +77,7 @@ function RegimenesActivosSection() {
useEffect(() => {
if (activos && catalogo) {
const ids = new Set(activos.map(a => catalogo.find(c => c.clave === a.clave)?.id).filter(Boolean) as number[]);
const ids = new Set(activos.map((a: { clave: string }) => catalogo.find((c: { clave: string; id: number }) => c.clave === a.clave)?.id).filter(Boolean) as number[]);
setSelected(ids);
}
}, [activos, catalogo]);

View File

@@ -2,9 +2,10 @@
import { useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, Button } from '@horux/shared-ui';
import { CheckCircle2, Server, Cloud, Clock, ExternalLink, CreditCard } from 'lucide-react';
import { CheckCircle2, Server, Cloud, Clock, ExternalLink, CreditCard, Gift } from 'lucide-react';
import { apiClient } from '@/lib/api/client';
import { subscribeMe, changeMyPlan, cancelMySubscription, upgradeMe, generatePaymentLink } from '@/lib/api/subscription';
import { getPendingInvitation, acceptInvitation } from '@/lib/api/trial-invitations';
import { useAuthStore } from '@/stores/auth-store';
type Despachoplan = 'trial' | 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus' | 'custom';
@@ -37,7 +38,7 @@ export default function PlanesDespachoPage() {
const { user } = useAuthStore();
const [planInfo, setPlanInfo] = useState<PlanInfo | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<null | PaidPlan | 'cancel' | 'pay-now'>(null);
const [busy, setBusy] = useState<null | PaidPlan | 'cancel' | 'pay-now' | 'accept-invite'>(null);
const [message, setMessage] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
// Toggle mensual/anual solo aplica a Mi Empresa y Mi Empresa+. Business
// Control y Enterprise siempre se cobran anual. Default monthly para
@@ -45,6 +46,12 @@ export default function PlanesDespachoPage() {
// muestra como CTA secundario.
const [meFreq, setMeFreq] = useState<Frequency>('monthly');
const [mePlusFreq, setMePlusFreq] = useState<Frequency>('monthly');
const [pendingInvitation, setPendingInvitation] = useState<{
id: string;
plan: string;
durationDays: number;
token: string;
} | null>(null);
const fetchPlan = () => {
apiClient.get<PlanInfo>('/despachos/me/plan')
@@ -58,6 +65,20 @@ export default function PlanesDespachoPage() {
.then(res => setPlanInfo(res.data))
.catch(() => setPlanInfo(null))
.finally(() => setLoading(false));
// Cargar invitación de trial pendiente
getPendingInvitation()
.then((inv) => {
if (inv && inv.status === 'pending') {
setPendingInvitation({
id: inv.id,
plan: inv.plan,
durationDays: inv.durationDays,
token: inv.token,
});
}
})
.catch(() => {});
}, []);
const currentPlan = planInfo?.plan ?? null;
@@ -153,6 +174,22 @@ export default function PlanesDespachoPage() {
}
}
async function handleAcceptInvitation() {
if (!pendingInvitation) return;
setBusy('accept-invite');
setMessage(null);
try {
const result = await acceptInvitation(pendingInvitation.token);
setMessage({ kind: 'ok', text: `¡Activado! Tienes ${result.durationDays} días de Business Control Prueba.` });
setPendingInvitation(null);
fetchPlan();
} catch (err: any) {
setMessage({ kind: 'err', text: err?.response?.data?.message || err?.message || 'Error al activar la invitación' });
} finally {
setBusy(null);
}
}
function ActiveBadge() {
return (
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-green-600 text-white text-xs px-3 py-1 rounded-full font-medium whitespace-nowrap">
@@ -242,6 +279,28 @@ export default function PlanesDespachoPage() {
</div>
)}
{/* Banner de invitación de trial pendiente */}
{!loading && pendingInvitation && (
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 bg-purple-50 dark:bg-purple-950 border border-purple-200 dark:border-purple-800 rounded-lg px-5 py-4 max-w-3xl mx-auto">
<Gift className="h-6 w-6 text-purple-600 dark:text-purple-400 flex-shrink-0" />
<div className="flex-1 text-sm">
<div className="font-semibold text-purple-900 dark:text-purple-200">
Invitación especial Business Control Prueba
</div>
<div className="text-purple-700 dark:text-purple-400">
Tienes una invitación para probar Business Control por <strong>{pendingInvitation.durationDays} días</strong> con todas las funciones.
</div>
</div>
<Button
onClick={handleAcceptInvitation}
disabled={busy === 'accept-invite'}
className="w-full sm:w-auto"
>
{busy === 'accept-invite' ? 'Activando...' : 'Activar ahora'}
</Button>
</div>
)}
{/* Banner de suscripción activa */}
{!loading && planInfo?.subscription && hasPaidPlan && (() => {
const sub = planInfo.subscription;

View File

@@ -78,7 +78,7 @@ export default function ContribuyentesPage() {
};
return (
<div className="p-6 space-y-6">
<div className="p-6 space-y-6 max-w-7xl mx-auto">
<div className="flex items-center justify-between">
<div><h1 className="text-2xl font-bold">Contribuyentes</h1><p className="text-sm text-muted-foreground">RFCs que gestiona tu despacho</p></div>
<Button

View File

@@ -1,15 +1,22 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@horux/shared-ui';
import { Card, CardContent, CardHeader, CardTitle, CardDescription, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@horux/shared-ui';
import { Header } from '@/components/layouts/header';
import { DespachoSubnav } from '@/components/despachos/despacho-subnav';
import { PeriodoSelector } from '@/components/periodo-selector';
import { apiClient } from '@/lib/api/client';
import { useAuthStore } from '@/stores/auth-store';
import { useTenantViewStore } from '@/stores/tenant-view-store';
import { usePeriodoStore, añoMesFromFechaInicio } from '@/stores/periodo-store';
import { Building2, RefreshCw, Loader2, TrendingUp, FileCheck, DollarSign, AlertTriangle } from 'lucide-react';
interface Despacho {
id: string;
nombre: string;
rfc: string;
}
interface Stats {
totalContribuyentes: number;
ultimaExtraccion: string | null;
@@ -20,14 +27,28 @@ interface Stats {
tareasAtrasadas: number;
}
const PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']);
export default function DespachoContribuyentesPage() {
const role = useAuthStore(s => s.user?.role);
const enabled = role === 'owner' || role === 'cfo';
const platformRoles = useAuthStore(s => s.user?.platformRoles);
const isPlatformStaff = platformRoles?.some(r => PLATFORM_SUPERSET.has(r)) ?? false;
const enabled = role === 'owner' || role === 'cfo' || isPlatformStaff;
const { fechaInicio } = usePeriodoStore();
const { año, mes } = añoMesFromFechaInicio(fechaInicio);
const { viewingTenantId, setViewingTenant } = useTenantViewStore();
const { data: despachos } = useQuery<Despacho[]>({
queryKey: ['admin-despachos'],
queryFn: async () => {
const { data } = await apiClient.get<{ data: Despacho[] }>('/admin/dashboard/despachos');
return data.data;
},
enabled: isPlatformStaff,
});
const { data, isLoading } = useQuery<Stats>({
queryKey: ['despacho-contribuyentes-stats', año, mes],
queryKey: ['despacho-contribuyentes-stats', viewingTenantId, año, mes],
queryFn: async () => {
const { data } = await apiClient.get<Stats>(`/despachos/contribuyentes-stats?año=${año}&mes=${mes}`);
return data;
@@ -56,6 +77,31 @@ export default function DespachoContribuyentesPage() {
<Header title="Despacho — Contribuyentes"><PeriodoSelector /></Header>
<main className="p-6 max-w-7xl mx-auto">
<DespachoSubnav />
{isPlatformStaff && despachos && despachos.length > 0 && (
<div className="mb-6">
<label className="block text-sm font-medium text-muted-foreground mb-2">
Ver despacho
</label>
<Select
value={viewingTenantId || ''}
onValueChange={(value) => {
const d = despachos.find((x) => x.id === value);
setViewingTenant(value || null, d?.nombre || null, d?.rfc || null);
}}
>
<SelectTrigger className="w-full max-w-md">
<SelectValue placeholder="Selecciona un despacho" />
</SelectTrigger>
<SelectContent>
{despachos.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.nombre} ({d.rfc})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{isLoading ? (
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">

View File

@@ -25,11 +25,14 @@ interface Asignado {
tareasCompletadas: number;
}
const ROLES_ASIGNADOS = new Set(['owner', 'cfo', 'supervisor', 'auxiliar']);
const ROLES_ASIGNADOS = new Set(['owner', 'cfo', 'supervisor', 'auxiliar', 'contador', 'visor']);
const PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']);
export default function MisAsignadosPage() {
const role = useAuthStore(s => s.user?.role);
const enabled = role ? ROLES_ASIGNADOS.has(role) : false;
const platformRoles = useAuthStore(s => s.user?.platformRoles);
const isPlatformStaff = platformRoles?.some(r => PLATFORM_SUPERSET.has(r)) ?? false;
const enabled = role ? (ROLES_ASIGNADOS.has(role) || isPlatformStaff) : false;
const { setSelectedContribuyente } = useContribuyenteStore();
const { fechaInicio } = usePeriodoStore();
const { año, mes } = añoMesFromFechaInicio(fechaInicio);

View File

@@ -8,9 +8,10 @@ import { defaultDespachoPathForRole } from '@/components/despachos/despacho-subn
export default function DespachosIndex() {
const router = useRouter();
const role = useAuthStore(s => s.user?.role);
const platformRoles = useAuthStore(s => s.user?.platformRoles);
useEffect(() => {
if (!role) return;
router.replace(defaultDespachoPathForRole(role));
}, [role, router]);
router.replace(defaultDespachoPathForRole(role, platformRoles));
}, [role, platformRoles, router]);
return null;
}

View File

@@ -91,7 +91,7 @@ export default function OnboardingPage() {
title: 'Subir FIEL del contribuyente',
description: 'Necesaria para sincronizar con el SAT.',
icon: <Key className="h-5 w-5" />,
href: '/contribuyentes',
href: '/configuracion/sat',
completed: fielDone,
},
{
@@ -99,7 +99,7 @@ export default function OnboardingPage() {
title: 'Subir CSD (para emitir facturas)',
description: 'Certificado de Sello Digital para timbrado.',
icon: <FileText className="h-5 w-5" />,
href: '/contribuyentes',
href: '/configuracion/csd',
completed: csdDone,
},
{
@@ -178,11 +178,11 @@ export default function OnboardingPage() {
<p className="text-sm text-muted-foreground">{step.description}</p>
</div>
{!step.completed && step.href !== '#' && (
<Link href={step.href}>
<Button variant="outline" size="sm" className="flex items-center gap-1">
<Button variant="outline" size="sm" className="flex items-center gap-1" asChild>
<Link href={step.href}>
Configurar <ArrowRight className="h-3 w-3" />
</Button>
</Link>
</Link>
</Button>
)}
</CardContent>
</Card>

View File

@@ -14,7 +14,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
import Link from 'next/link';
import { cn } from '@horux/shared-ui';
import { isDespachoTenant } from '@horux/shared';
import type { Role } from '@horux/shared';
import type { Role, UserInvite } from '@horux/shared';
// ── Horux360 legacy roles ─────────────────────────────────────────────────────
const legacyRoleLabels: Record<string, { label: string; icon: React.ElementType; color: string }> = {
@@ -83,10 +83,10 @@ export default function UsuariosPage() {
const isAdmin = currentUser?.role === 'owner' || currentUser?.role === 'cfo';
const [showInvite, setShowInvite] = useState(false);
const [inviteForm, setInviteForm] = useState<{ email: string; nombre: string; role: Role; supervisorUserId?: string }>({
const [inviteForm, setInviteForm] = useState<{ email: string; nombre: string; role: UserInvite['role']; supervisorUserId?: string }>({
email: '',
nombre: '',
role: defaultInviteRole as Role,
role: defaultInviteRole as UserInvite['role'],
});
const [selectedRfcIds, setSelectedRfcIds] = useState<string[]>([]);
@@ -183,7 +183,7 @@ export default function UsuariosPage() {
);
}
setShowInvite(false);
setInviteForm({ email: '', nombre: '', role: defaultInviteRole as Role, supervisorUserId: undefined });
setInviteForm({ email: '', nombre: '', role: defaultInviteRole as UserInvite['role'], supervisorUserId: undefined });
setSelectedRfcIds([]);
} catch (error: any) {
alert(error.response?.data?.message || 'Error al invitar usuario');
@@ -211,11 +211,11 @@ export default function UsuariosPage() {
</div>
<div className="flex items-center gap-2">
{isAdmin && isDespacho && (
<Link href="/carteras">
<Button variant="outline" className="flex items-center gap-2">
<Button variant="outline" className="flex items-center gap-2" asChild>
<Link href="/carteras">
<FolderOpen className="h-4 w-4" /> Gestionar Carteras
</Button>
</Link>
</Link>
</Button>
)}
{isAdmin && (
<Button onClick={() => setShowInvite(true)}>
@@ -263,13 +263,13 @@ export default function UsuariosPage() {
<Label htmlFor="role">Rol</Label>
<Select
value={inviteForm.role}
onValueChange={(v) => { setInviteForm({ ...inviteForm, role: v as Role, supervisorUserId: undefined }); if (v !== 'cliente') setSelectedRfcIds([]); }}
onValueChange={(v) => { setInviteForm({ ...inviteForm, role: v as UserInvite['role'], supervisorUserId: undefined }); if (v !== 'cliente') setSelectedRfcIds([]); }}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{inviteRoles.map(r => (
{inviteRoles.map((r: { value: string; label: string; description?: string }) => (
<SelectItem key={r.value} value={r.value}>
<div className="flex flex-col">
<span>{r.label}</span>

View File

@@ -0,0 +1,200 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { Button, Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { useAuthStore } from '@/stores/auth-store';
import { CheckCircle2, Clock, AlertTriangle, Loader2 } from 'lucide-react';
import Link from 'next/link';
interface InvitationData {
id: string;
tenantId: string;
plan: string;
durationDays: number;
status: string;
expiresAt: string;
tenant: {
nombre: string;
rfc: string;
} | null;
}
export default function InvitacionTrialPage() {
const params = useParams();
const router = useRouter();
const { user } = useAuthStore();
const token = typeof params.token === 'string' ? params.token : '';
const [invitation, setInvitation] = useState<InvitationData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [accepting, setAccepting] = useState(false);
const [accepted, setAccepted] = useState(false);
useEffect(() => {
if (!token) {
setError('Token de invitación inválido');
setLoading(false);
return;
}
apiClient.get<InvitationData>(`/invitations/trial/token/${token}`)
.then((res) => {
setInvitation(res.data);
setLoading(false);
})
.catch((err: any) => {
setError(err.response?.data?.message || 'Invitación no encontrada o expirada');
setLoading(false);
});
}, [token]);
async function handleAccept() {
if (!token) return;
setAccepting(true);
setError('');
try {
await apiClient.post(`/invitations/trial/${token}/accept`);
setAccepted(true);
setTimeout(() => {
router.push('/configuracion/planes-despacho');
}, 2000);
} catch (err: any) {
setError(err.response?.data?.message || 'Error al aceptar la invitación');
} finally {
setAccepting(false);
}
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (error && !invitation) {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<AlertTriangle className="h-12 w-12 text-amber-500 mx-auto mb-2" />
<CardTitle>Invitación no válida</CardTitle>
</CardHeader>
<CardContent className="text-center space-y-4">
<p className="text-muted-foreground">{error}</p>
<Button className="w-full" asChild>
<Link href="/login">Ir al inicio de sesión</Link>
</Button>
</CardContent>
</Card>
</div>
);
}
if (accepted) {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CheckCircle2 className="h-12 w-12 text-green-500 mx-auto mb-2" />
<CardTitle>¡Invitación aceptada!</CardTitle>
</CardHeader>
<CardContent className="text-center space-y-4">
<p className="text-muted-foreground">
Tu despacho ahora tiene acceso a <strong>Business Control Prueba</strong> por {invitation?.durationDays} días.
</p>
<p className="text-sm text-muted-foreground">Redirigiendo a tu panel...</p>
</CardContent>
</Card>
</div>
);
}
const isExpired = invitation ? new Date(invitation.expiresAt) < new Date() : false;
const isPending = invitation?.status === 'pending';
const planDisplay = invitation?.plan === 'business_control' ? 'Business Control' : invitation?.plan;
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-purple-50 dark:from-gray-900 dark:to-gray-800 p-4">
<Card className="w-full max-w-lg">
<CardHeader className="text-center">
<div className="mx-auto bg-blue-100 dark:bg-blue-900 rounded-full p-3 w-fit mb-2">
<Clock className="h-6 w-6 text-blue-600 dark:text-blue-400" />
</div>
<CardTitle className="text-2xl">Invitación especial</CardTitle>
<p className="text-muted-foreground">
Has sido invitado a probar <strong>{planDisplay}</strong>
</p>
</CardHeader>
<CardContent className="space-y-6">
<div className="bg-muted rounded-lg p-4 space-y-3">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Despacho</span>
<span className="text-sm font-medium">{invitation?.tenant?.nombre || '—'}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Plan</span>
<span className="text-sm font-medium">{planDisplay}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Duración</span>
<span className="text-sm font-medium">{invitation?.durationDays} días</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Expira el</span>
<span className="text-sm font-medium">
{invitation?.expiresAt
? new Date(invitation.expiresAt).toLocaleDateString('es-MX')
: '—'}
</span>
</div>
</div>
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h4 className="text-sm font-semibold text-blue-800 dark:text-blue-300 mb-2">¿Qué incluye?</h4>
<ul className="text-sm text-blue-700 dark:text-blue-400 space-y-1">
<li> Hasta 100 RFCs</li>
<li> Usuarios ilimitados</li>
<li> API de integración</li>
<li> SAT incremental</li>
<li> Todas las funciones de Business Control</li>
</ul>
</div>
{error && (
<p className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">{error}</p>
)}
{!user ? (
<div className="space-y-3">
<p className="text-sm text-muted-foreground text-center">
Debes iniciar sesión con la cuenta del dueño del despacho para aceptar esta invitación.
</p>
<Button className="w-full" asChild>
<Link href={`/login?redirect=/invitacion/trial/${token}`}>Iniciar sesión</Link>
</Button>
</div>
) : isExpired || !isPending ? (
<div className="text-center">
<p className="text-sm text-muted-foreground">
Esta invitación ya no está disponible ({invitation?.status}).
</p>
</div>
) : (
<Button
className="w-full"
onClick={handleAccept}
disabled={accepting}
>
{accepting ? 'Activando...' : 'Aceptar invitación'}
</Button>
)}
</CardContent>
</Card>
</div>
);
}