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:
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
169
apps/web/app/(dashboard)/admin/facturas-pendientes/page.tsx
Normal file
169
apps/web/app/(dashboard)/admin/facturas-pendientes/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
260
apps/web/app/(dashboard)/admin/invitaciones-trial/page.tsx
Normal file
260
apps/web/app/(dashboard)/admin/invitaciones-trial/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 >$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 >$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">
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
200
apps/web/app/invitacion/trial/[token]/page.tsx
Normal file
200
apps/web/app/invitacion/trial/[token]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user