Update: nueva version Horux Despachos

This commit is contained in:
consultoria-as
2026-04-27 22:09:36 -06:00
commit 6b36db1403
614 changed files with 125926 additions and 0 deletions

1
apps/web/.env.example Normal file
View File

@@ -0,0 +1 @@
NEXT_PUBLIC_API_URL=http://localhost:4000/api

View File

@@ -0,0 +1,118 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { Button, Input, Label, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@horux/shared-ui';
import { requestPasswordReset } from '@/lib/api/auth';
import { Mail, CheckCircle2 } from 'lucide-react';
export default function ForgotPasswordPage() {
const [isLoading, setIsLoading] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState('');
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setIsLoading(true);
setError('');
const formData = new FormData(e.currentTarget);
const email = formData.get('email') as string;
try {
await requestPasswordReset(email);
setSubmitted(true);
} catch (err: any) {
// Rate limit u otro error explícito
setError(err.response?.data?.message || 'Error al enviar el enlace. Intenta más tarde.');
} finally {
setIsLoading(false);
}
}
if (submitted) {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<div className="rounded-full bg-green-100 p-3">
<CheckCircle2 className="h-10 w-10 text-green-600" />
</div>
</div>
<CardTitle>Revisa tu correo</CardTitle>
<CardDescription>
Si el email que ingresaste está registrado, recibirás un enlace para restablecer tu contraseña.
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-lg bg-muted/40 border p-4 text-sm text-muted-foreground">
<p className="font-medium text-foreground mb-1">¿No recibiste el correo?</p>
<ul className="list-disc pl-5 space-y-1 text-xs">
<li>Revisa tu carpeta de spam o promociones</li>
<li>El enlace expira en 1 hora si pasó más tiempo, vuelve a solicitar</li>
<li>Asegúrate de haber escrito bien tu email</li>
</ul>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-2">
<Button variant="outline" className="w-full" onClick={() => setSubmitted(false)}>
Solicitar de nuevo
</Button>
<Link href="/login" className="text-sm text-primary hover:underline">
Volver al login
</Link>
</CardFooter>
</Card>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<Image src="/logo.jpg" alt="Horux360" width={80} height={80} className="rounded-full" priority />
</div>
<CardTitle className="text-2xl flex items-center justify-center gap-2">
<Mail className="h-5 w-5" />
Recuperar contraseña
</CardTitle>
<CardDescription>
Ingresa tu email y te enviaremos un enlace para crear una nueva contraseña.
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{error && (
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="tu@email.com"
required
autoFocus
/>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-4">
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Enviando...' : 'Enviar enlace'}
</Button>
<Link href="/login" className="text-sm text-primary hover:underline">
Volver al login
</Link>
</CardFooter>
</form>
</Card>
</div>
);
}

View File

@@ -0,0 +1,11 @@
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-gradient-to-b from-background to-muted">
{children}
</div>
);
}

View File

@@ -0,0 +1,121 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
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';
export default function LoginPage() {
const router = useRouter();
const { setUser, setTokens } = useAuthStore();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setIsLoading(true);
setError('');
const formData = new FormData(e.currentTarget);
const email = formData.get('email') as string;
const password = formData.get('password') as string;
try {
const response = await login({ email, password });
setTokens(response.accessToken, response.refreshToken);
setUser(response.user);
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 isGlobalAdmin = isGlobalAdminRfc(response.user?.tenantRfc, userRole, platformRoles);
if (isGlobalAdmin) {
router.push('/clientes');
} else if (userRole === 'cliente' || userRole === 'auxiliar' || userRole === 'supervisor') {
// Clients and roles without onboarding go straight to dashboard
router.push('/dashboard');
} else {
const STORAGE_KEY = 'horux360:onboarding_seen_v1';
const seen = typeof window !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1';
router.push(seen ? '/dashboard' : '/onboarding');
}
} catch (err: any) {
setError(err.response?.data?.message || 'Error al iniciar sesión');
} finally {
setIsLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<Image
src="/logo.jpg"
alt="Horux360"
width={80}
height={80}
className="rounded-full"
priority
/>
</div>
<CardTitle className="text-2xl">Iniciar Sesión</CardTitle>
<CardDescription>
Ingresa tus credenciales para acceder a tu cuenta
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{error && (
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="tu@email.com"
required
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Contraseña</Label>
<Link href="/forgot-password" className="text-xs text-primary hover:underline">
¿Olvidaste tu contraseña?
</Link>
</div>
<Input
id="password"
name="password"
type="password"
placeholder="••••••••"
required
/>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-4">
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Iniciando sesión...' : 'Iniciar Sesión'}
</Button>
<p className="text-sm text-muted-foreground text-center">
¿No tienes cuenta?{' '}
<Link href="/register-despacho" className="text-primary hover:underline">
Regístrate
</Link>
</p>
</CardFooter>
</form>
</Card>
</div>
);
}

View File

@@ -0,0 +1,289 @@
'use client';
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 { useAuthStore } from '@/stores/auth-store';
import { apiClient } from '@/lib/api/client';
import { CheckCircle2, Server, Cloud, ArrowLeft, Clock } from 'lucide-react';
type VerticalProfile = 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
type PlanType = 'trial' | '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);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [form, setForm] = useState({
despachoNombre: '',
ownerNombre: '',
ownerEmail: '',
ownerPassword: '',
acceptedTerms: false,
});
const handleChange = (field: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
setForm((prev) => ({ ...prev, [field]: e.target.value }));
setError('');
};
const handleSubmit = async () => {
if (!form.acceptedTerms) { setError('Debes aceptar los términos y condiciones'); return; }
if (!verticalProfile || !selectedPlan) { setError('Completa todos los pasos'); return; }
setLoading(true);
setError('');
try {
const { data } = await apiClient.post('/despachos/signup', {
despacho: {
nombre: form.despachoNombre,
verticalProfile,
plan: selectedPlan,
},
owner: {
nombre: form.ownerNombre,
email: form.ownerEmail,
password: form.ownerPassword,
},
});
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');
}
} catch (err: any) {
setError(err.response?.data?.message || 'Error al registrar el despacho');
setStep(1);
} finally {
setLoading(false);
}
};
// =================== STEP 1: Registration Form ===================
if (step === 1) {
const canProceed = form.despachoNombre && form.ownerNombre && form.ownerEmail && form.ownerPassword.length >= 10 && form.acceptedTerms;
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="flex items-center justify-center gap-2 text-xs text-muted-foreground mb-4">
<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>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-3">
<div><Label htmlFor="dn">Nombre del despacho</Label><Input id="dn" value={form.despachoNombre} onChange={handleChange('despachoNombre')} placeholder="Despacho Pérez y Asociados" required /></div>
</div>
<div className="space-y-3">
<div><Label htmlFor="on">Tu nombre completo</Label><Input id="on" value={form.ownerNombre} onChange={handleChange('ownerNombre')} placeholder="Juan Pérez" required /></div>
<div><Label htmlFor="oe">Email</Label><Input id="oe" type="email" value={form.ownerEmail} onChange={handleChange('ownerEmail')} placeholder="juan@despacho.com" required /></div>
<div><Label htmlFor="op">Contraseña</Label><Input id="op" type="password" value={form.ownerPassword} onChange={handleChange('ownerPassword')} placeholder="Mínimo 10 caracteres" minLength={10} required /></div>
</div>
<div className="flex items-start gap-2">
<input type="checkbox" id="terms" checked={form.acceptedTerms} onChange={(e) => setForm((p) => ({ ...p, acceptedTerms: e.target.checked }))} className="mt-1" />
<label htmlFor="terms" className="text-sm text-muted-foreground">Acepto los <Link href="/terminos" target="_blank" className="underline text-primary">términos y condiciones</Link></label>
</div>
{error && <p className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">{error}</p>}
<Button onClick={() => canProceed && setStep(2)} className="w-full" disabled={!canProceed}>
Continuar
</Button>
<p className="text-center text-sm text-muted-foreground">
¿Ya tienes cuenta? <Link href="/login" className="text-primary underline">Inicia sesión</Link>
</p>
</CardContent>
</Card>
</div>
);
}
// =================== 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="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>
</div>
<h1 className="text-3xl font-bold">Elige tu plan</h1>
<p className="text-muted-foreground mt-2">Todos los planes incluyen las mismas funcionalidades.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Trial Gratuito */}
<Card
className={cn(
'cursor-pointer transition-all hover:shadow-lg',
selectedPlan === 'trial' && 'border-primary ring-2 ring-primary/20'
)}
onClick={() => setSelectedPlan('trial')}
>
<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-xl">Trial Gratuito</CardTitle>
<p className="text-sm text-muted-foreground">Prueba sin compromiso</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-center">
<div className="text-3xl font-bold">$0</div>
<p className="text-sm text-muted-foreground">por 30 días</p>
<p className="text-xs text-muted-foreground mt-1">Sin tarjeta de crédito</p>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 3 RFCs</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>20 timbres incluidos</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Todas las funcionalidades</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Base de datos en la nube</span></div>
</div>
</CardContent>
</Card>
{/* Business Control */}
<Card
className={cn(
'cursor-pointer transition-all hover:shadow-lg',
selectedPlan === 'business_control' && 'border-primary ring-2 ring-primary/20'
)}
onClick={() => setSelectedPlan('business_control')}
>
<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-xl">Business Control</CardTitle>
<p className="text-sm text-muted-foreground">Tu servidor, tus datos</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-center">
<div className="text-3xl font-bold">$21,000</div>
<p className="text-sm text-muted-foreground">primer año (IVA incluido)</p>
<p className="text-xs text-muted-foreground mt-1">$15,000/año a partir del 2do año</p>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Base de datos en tu servidor</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>RFCs ilimitados</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Usuarios ilimitados</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Control total de tus datos</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Requiere Docker en tu servidor</span></div>
</div>
</CardContent>
</Card>
{/* Business Cloud */}
<Card
className={cn(
'cursor-pointer transition-all hover:shadow-lg relative',
selectedPlan === 'business_cloud' && 'border-primary ring-2 ring-primary/20'
)}
onClick={() => setSelectedPlan('business_cloud')}
>
<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-purple-100 dark:bg-purple-900 rounded-full p-3 w-fit mb-2">
<Cloud className="h-6 w-6 text-purple-600 dark:text-purple-400" />
</div>
<CardTitle className="text-xl">Business Cloud</CardTitle>
<p className="text-sm text-muted-foreground">Nosotros lo operamos por ti</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-center">
<div className="text-3xl font-bold">$15,000</div>
<p className="text-sm text-muted-foreground">por año (fijo)</p>
<p className="text-xs text-muted-foreground mt-1">+ $45/mes por cada RFC gestionado</p>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Base de datos en la nube (Horux)</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Sin infraestructura propia</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Usuarios ilimitados</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Backups automáticos</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Soporte prioritario</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
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,145 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Button, Input, Label, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@horux/shared-ui';
import { register } from '@/lib/api/auth';
import { useAuthStore } from '@/stores/auth-store';
export default function RegisterPage() {
const router = useRouter();
const { setUser, setTokens } = useAuthStore();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [acceptedTerms, setAcceptedTerms] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!acceptedTerms) {
setError('Debes aceptar los términos y condiciones para continuar.');
return;
}
setIsLoading(true);
setError('');
const formData = new FormData(e.currentTarget);
try {
const response = await register({
empresa: {
nombre: formData.get('empresaNombre') as string,
rfc: formData.get('empresaRfc') as string,
},
usuario: {
nombre: formData.get('nombre') as string,
email: formData.get('email') as string,
password: formData.get('password') as string,
},
});
setTokens(response.accessToken, response.refreshToken);
setUser(response.user);
router.push('/dashboard');
} catch (err: any) {
setError(err.response?.data?.message || 'Error al registrarse');
} finally {
setIsLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Crear Cuenta</CardTitle>
<CardDescription>
Registra tu empresa y comienza tu prueba gratuita
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{error && (
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
Datos de la Empresa
</Label>
<Input
name="empresaNombre"
placeholder="Nombre de la empresa"
required
/>
<Input
name="empresaRfc"
placeholder="RFC (ej: ABC123456XY1)"
maxLength={13}
required
/>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
Datos del Dueño
</Label>
<Input
name="nombre"
placeholder="Tu nombre completo"
required
/>
<Input
name="email"
type="email"
placeholder="tu@email.com"
required
/>
<Input
name="password"
type="password"
placeholder="Contraseña (mín. 8 caracteres)"
minLength={8}
required
/>
</div>
<label className="flex items-start gap-2 text-sm cursor-pointer select-none pt-2">
<input
type="checkbox"
checked={acceptedTerms}
onChange={(e) => setAcceptedTerms(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-input accent-primary cursor-pointer"
required
/>
<span className="text-muted-foreground">
Acepto los{' '}
<Link
href="/terminos"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline hover:no-underline"
>
Términos y Condiciones
</Link>
{' '}de Horux 360.
</span>
</label>
</CardContent>
<CardFooter className="flex flex-col gap-4">
<Button type="submit" className="w-full" disabled={isLoading || !acceptedTerms}>
{isLoading ? 'Creando cuenta...' : 'Crear Cuenta Gratis'}
</Button>
<p className="text-sm text-muted-foreground text-center">
¿Ya tienes cuenta?{' '}
<Link href="/login" className="text-primary hover:underline">
Inicia sesión
</Link>
</p>
</CardFooter>
</form>
</Card>
</div>
);
}

View File

@@ -0,0 +1,166 @@
'use client';
import { Suspense, useState } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { useRouter, useSearchParams } from 'next/navigation';
import { Button, Input, Label, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@horux/shared-ui';
import { confirmPasswordReset } from '@/lib/api/auth';
import { KeyRound, CheckCircle2, AlertTriangle } from 'lucide-react';
function ResetPasswordContent() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get('token') || '';
const [isLoading, setIsLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
if (!token) {
return (
<Card>
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<div className="rounded-full bg-red-100 p-3">
<AlertTriangle className="h-10 w-10 text-red-600" />
</div>
</div>
<CardTitle>Enlace inválido</CardTitle>
<CardDescription>
El enlace no incluye un token de recuperación válido. Solicita uno nuevo desde la pantalla de login.
</CardDescription>
</CardHeader>
<CardFooter>
<Link href="/forgot-password" className="w-full">
<Button className="w-full">Solicitar nuevo enlace</Button>
</Link>
</CardFooter>
</Card>
);
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError('');
if (newPassword.length < 8) {
setError('La contraseña debe tener al menos 8 caracteres');
return;
}
if (newPassword !== confirmPassword) {
setError('Las contraseñas no coinciden');
return;
}
setIsLoading(true);
try {
await confirmPasswordReset(token, newPassword);
setSuccess(true);
setTimeout(() => router.push('/login'), 3000);
} catch (err: any) {
setError(err.response?.data?.message || 'Error al actualizar contraseña');
} finally {
setIsLoading(false);
}
}
if (success) {
return (
<Card>
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<div className="rounded-full bg-green-100 p-3">
<CheckCircle2 className="h-10 w-10 text-green-600" />
</div>
</div>
<CardTitle>Contraseña actualizada</CardTitle>
<CardDescription>
Tu contraseña fue cambiada exitosamente. Redireccionando al login...
</CardDescription>
</CardHeader>
<CardFooter>
<Link href="/login" className="w-full">
<Button className="w-full">Ir al login</Button>
</Link>
</CardFooter>
</Card>
);
}
return (
<Card>
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<Image src="/logo.jpg" alt="Horux360" width={80} height={80} className="rounded-full" priority />
</div>
<CardTitle className="text-2xl flex items-center justify-center gap-2">
<KeyRound className="h-5 w-5" />
Nueva contraseña
</CardTitle>
<CardDescription>
Ingresa tu nueva contraseña. Mínimo 8 caracteres.
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{error && (
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="newPassword">Nueva contraseña</Label>
<Input
id="newPassword"
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
placeholder="••••••••"
required
minLength={8}
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirmar contraseña</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
placeholder="••••••••"
required
minLength={8}
/>
</div>
<p className="text-xs text-muted-foreground">
Al actualizar, se cerrarán todas tus sesiones activas por seguridad. Tendrás que volver a iniciar sesión.
</p>
</CardContent>
<CardFooter className="flex flex-col gap-2">
<Button type="submit" className="w-full" disabled={isLoading || !newPassword || !confirmPassword}>
{isLoading ? 'Actualizando...' : 'Actualizar contraseña'}
</Button>
<Link href="/login" className="text-sm text-primary hover:underline">
Volver al login
</Link>
</CardFooter>
</form>
</Card>
);
}
export default function ResetPasswordPage() {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-md">
<Suspense fallback={<Card><CardContent className="py-8 text-center">Cargando...</CardContent></Card>}>
<ResetPasswordContent />
</Suspense>
</div>
</div>
);
}

View File

@@ -0,0 +1,229 @@
'use client';
import { useState } from 'react';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import { isGlobalAdminRfc } from '@horux/shared';
import { useAuditLog } from '@/lib/hooks/use-audit-log';
import { ChevronLeft, ChevronRight, Search, X, FileWarning, ShieldAlert } from 'lucide-react';
const ACTION_GROUPS = [
{ value: '', label: 'Todas las acciones' },
{ value: 'user.', label: 'Usuarios (login, logout, password)' },
{ value: 'subscription.', label: 'Suscripciones (crear, cancelar, cambiar, reactivar)' },
{ value: 'trial.', label: 'Trials' },
{ value: 'price.', label: 'Precios' },
{ value: 'invoice.', label: 'Facturación' },
{ value: 'payment.', label: 'Pagos' },
{ value: 'tenant.', label: 'Tenants' },
{ value: 'fiel.', label: 'FIEL' },
];
const ACTION_LABELS: Record<string, { label: string; color: string }> = {
'user.login': { label: 'Login', color: 'bg-blue-50 text-blue-700' },
'user.logout': { label: 'Logout', color: 'bg-slate-50 text-slate-700' },
'user.password_changed': { label: 'Cambio password', color: 'bg-amber-50 text-amber-700' },
'trial.started': { label: 'Trial iniciado', color: 'bg-sky-50 text-sky-700' },
'subscription.created': { label: 'Suscripción creada',color: 'bg-green-50 text-green-700' },
'subscription.cancelled': { label: 'Suscripción cancelada', color: 'bg-orange-50 text-orange-700' },
'subscription.reactivated': { label: 'Reactivada', color: 'bg-teal-50 text-teal-700' },
'subscription.plan_changed': { label: 'Cambio de plan', color: 'bg-indigo-50 text-indigo-700' },
'price.updated': { label: 'Precio editado', color: 'bg-purple-50 text-purple-700' },
'invoice.emitted_auto': { label: 'Factura auto', color: 'bg-emerald-50 text-emerald-700' },
'invoice.emitted_manual': { label: 'Factura manual', color: 'bg-emerald-50 text-emerald-700' },
'payment.marked_paid_manually': { label: 'Pago marcado manual', color: 'bg-lime-50 text-lime-700' },
};
function formatDateTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleString('es-MX', { dateStyle: 'short', timeStyle: 'medium' });
}
function ActionBadge({ action }: { action: string }) {
const cfg = ACTION_LABELS[action] || { label: action, color: 'bg-muted text-muted-foreground' };
return <span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${cfg.color}`}>{cfg.label}</span>;
}
export default function AuditLogPage() {
const { user } = useAuthStore();
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
const [filters, setFilters] = useState({
action: '',
tenantId: '',
userId: '',
from: '',
to: '',
page: 1,
limit: 50,
});
const [expandedId, setExpandedId] = useState<string | null>(null);
const { data, isLoading } = useAuditLog(filters);
if (!isGlobalAdmin) {
return (
<>
<Header title="Audit Log" />
<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 el audit log.
</p>
</CardContent>
</Card>
</main>
</>
);
}
const clearFilters = () => setFilters({ action: '', tenantId: '', userId: '', from: '', to: '', page: 1, limit: 50 });
return (
<>
<Header title="Audit Log" />
<main className="p-6 space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<FileWarning className="h-5 w-5" />
Filtros
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
<div className="space-y-1">
<Label>Acción</Label>
<Select value={filters.action || 'all'} onValueChange={v => setFilters(f => ({ ...f, action: v === 'all' ? '' : v, page: 1 }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{ACTION_GROUPS.map(g => (
<SelectItem key={g.value || 'all'} value={g.value || 'all'}>{g.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Tenant ID</Label>
<Input value={filters.tenantId} onChange={e => setFilters(f => ({ ...f, tenantId: e.target.value, page: 1 }))} placeholder="UUID del tenant" />
</div>
<div className="space-y-1">
<Label>User ID</Label>
<Input value={filters.userId} onChange={e => setFilters(f => ({ ...f, userId: e.target.value, page: 1 }))} placeholder="UUID del usuario" />
</div>
<div className="space-y-1">
<Label>Desde</Label>
<Input type="datetime-local" value={filters.from} onChange={e => setFilters(f => ({ ...f, from: e.target.value, page: 1 }))} />
</div>
<div className="space-y-1">
<Label>Hasta</Label>
<Input type="datetime-local" value={filters.to} onChange={e => setFilters(f => ({ ...f, to: e.target.value, page: 1 }))} />
</div>
</div>
<div className="flex gap-2 mt-4">
<Button variant="outline" size="sm" onClick={clearFilters}>
<X className="h-4 w-4 mr-1" />
Limpiar filtros
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">
Eventos {data?.total !== undefined && <span className="text-sm font-normal text-muted-foreground">({data.total.toLocaleString('es-MX')} totales)</span>}
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<p className="text-center py-8 text-muted-foreground">Cargando...</p>
) : !data || data.data.length === 0 ? (
<p className="text-center py-8 text-muted-foreground">No hay eventos con estos filtros.</p>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4 font-medium">Fecha</th>
<th className="py-2 pr-4 font-medium">Acción</th>
<th className="py-2 pr-4 font-medium">Usuario</th>
<th className="py-2 pr-4 font-medium">Tenant</th>
<th className="py-2 pr-4 font-medium">Entidad</th>
<th className="py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{data.data.map(row => (
<tr key={row.id} className="border-b last:border-0 hover:bg-muted/40">
<td className="py-3 pr-4 text-xs text-muted-foreground whitespace-nowrap">{formatDateTime(row.createdAt)}</td>
<td className="py-3 pr-4"><ActionBadge action={row.action} /></td>
<td className="py-3 pr-4">
{row.user ? (
<div className="text-xs">
<div className="font-medium">{row.user.nombre}</div>
<div className="text-muted-foreground">{row.user.email}</div>
</div>
) : <span className="text-muted-foreground text-xs">Sistema</span>}
</td>
<td className="py-3 pr-4">
{row.tenant ? (
<div className="text-xs">
<div className="font-medium">{row.tenant.nombre}</div>
<div className="text-muted-foreground font-mono">{row.tenant.rfc}</div>
</div>
) : <span className="text-muted-foreground text-xs"></span>}
</td>
<td className="py-3 pr-4 text-xs text-muted-foreground">
{row.entityType ? `${row.entityType}${row.entityId ? ` ${row.entityId.slice(0, 8)}` : ''}` : '—'}
</td>
<td className="py-3 text-right">
<Button variant="ghost" size="sm" onClick={() => setExpandedId(expandedId === row.id ? null : row.id)}>
{expandedId === row.id ? 'Ocultar' : 'Ver detalle'}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{expandedId && (() => {
const row = data.data.find(r => r.id === expandedId);
if (!row) return null;
return (
<Card className="mt-4 bg-muted/20">
<CardContent className="pt-4">
<p className="text-xs text-muted-foreground mb-2">Metadata del evento <code className="font-mono">{row.id}</code></p>
<pre className="text-xs whitespace-pre-wrap break-all bg-background p-3 rounded border">{JSON.stringify(row.metadata, null, 2)}</pre>
</CardContent>
</Card>
);
})()}
{data.totalPages > 1 && (
<div className="flex items-center justify-between pt-4 border-t mt-4">
<p className="text-sm text-muted-foreground">Página {data.page} de {data.totalPages}</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={data.page <= 1} onClick={() => setFilters(f => ({ ...f, page: f.page - 1 }))}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" disabled={data.page >= data.totalPages} onClick={() => setFilters(f => ({ ...f, page: f.page + 1 }))}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
</main>
</>
);
}

View File

@@ -0,0 +1,264 @@
'use client';
import { useState } from 'react';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import { isGlobalAdminRfc, type PlatformRole } from '@horux/shared';
import { useStaff, useSearchUsers, useGrantRole, useRevokeRole } from '@/lib/hooks/use-platform-staff';
import { ShieldAlert, Shield, ShieldCheck, HeadphonesIcon, TrendingUp, DollarSign, UserPlus, X, Loader2, Search, Cpu } from 'lucide-react';
const ROLE_META: Record<PlatformRole, { label: string; desc: string; icon: any; color: string }> = {
platform_admin: { label: 'Admin', desc: 'Todo: gestión staff, precios, clientes, facturas', icon: ShieldCheck, color: 'bg-red-100 text-red-700 border-red-200' },
platform_ti: { label: 'TI', desc: 'Equipo de TI. Mismos permisos que Admin (diferencia solo en trazabilidad)', icon: Cpu, color: 'bg-slate-100 text-slate-700 border-slate-200' },
platform_support: { label: 'Support', desc: 'Ver tenants, resolver tickets', icon: HeadphonesIcon, color: 'bg-blue-100 text-blue-700 border-blue-200' },
platform_sales: { label: 'Sales', desc: 'Crear/editar clientes, ver suscripciones', icon: TrendingUp, color: 'bg-green-100 text-green-700 border-green-200' },
platform_finance: { label: 'Finance', desc: 'Pagos, facturas manuales, editar precios', icon: DollarSign, color: 'bg-amber-100 text-amber-700 border-amber-200' },
};
const ALL_ROLES: PlatformRole[] = ['platform_admin', 'platform_ti', 'platform_support', 'platform_sales', 'platform_finance'];
export default function StaffPage() {
const { user } = useAuthStore();
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
const { data: staff = [], isLoading } = useStaff();
const grantRole = useGrantRole();
const revokeRole = useRevokeRole();
const [addOpen, setAddOpen] = useState(false);
const [searchQ, setSearchQ] = useState('');
const { data: candidates = [] } = useSearchUsers(searchQ);
const [pickedUserId, setPickedUserId] = useState<string | null>(null);
const [pickedRole, setPickedRole] = useState<PlatformRole>('platform_support');
if (!isGlobalAdmin) {
return (
<>
<Header title="Gestión de Staff" />
<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 platform_admin puede gestionar staff.</p>
</CardContent>
</Card>
</main>
</>
);
}
const handleGrant = async () => {
if (!pickedUserId) return;
try {
await grantRole.mutateAsync({ userId: pickedUserId, role: pickedRole });
setAddOpen(false);
setSearchQ('');
setPickedUserId(null);
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al asignar rol');
}
};
const handleRevoke = async (userId: string, role: PlatformRole, userEmail: string) => {
if (!confirm(`¿Quitar el rol "${ROLE_META[role].label}" a ${userEmail}?`)) return;
try {
await revokeRole.mutateAsync({ userId, role });
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al quitar rol');
}
};
return (
<>
<Header title="Gestión de Staff" />
<main className="p-6 space-y-6">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm text-muted-foreground">
Staff interno de Horux 360 con poderes transversales. <code className="font-mono text-xs">platform_admin</code> implica todos los otros roles.
</p>
</div>
<Button onClick={() => setAddOpen(true)}>
<UserPlus className="h-4 w-4 mr-1" />
Agregar staff
</Button>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Equipo ({staff.length})</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<p className="text-center py-8 text-muted-foreground">Cargando...</p>
) : staff.length === 0 ? (
<p className="text-center py-8 text-muted-foreground">
Todavía no hay staff. Agrega al primer miembro con el botón arriba.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4 font-medium">Usuario</th>
<th className="py-2 pr-4 font-medium">Tenant origen</th>
<th className="py-2 pr-4 font-medium">Roles</th>
<th className="py-2 font-medium text-right">Acciones</th>
</tr>
</thead>
<tbody>
{staff.map(s => (
<tr key={s.id} className="border-b last:border-0 hover:bg-muted/40">
<td className="py-3 pr-4">
<div className="font-medium">{s.nombre}</div>
<div className="text-xs text-muted-foreground">{s.email}</div>
</td>
<td className="py-3 pr-4 text-xs">
{s.tenant ? (
<div>
<div>{s.tenant.nombre}</div>
<div className="font-mono text-muted-foreground">{s.tenant.rfc}</div>
</div>
) : <span className="text-muted-foreground"></span>}
</td>
<td className="py-3 pr-4">
<div className="flex flex-wrap gap-1.5">
{s.roles.map(r => {
const meta = ROLE_META[r];
const Icon = meta.icon;
return (
<span key={r} className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium border ${meta.color}`}>
<Icon className="h-3 w-3" />
{meta.label}
<button
type="button"
onClick={() => handleRevoke(s.id, r, s.email)}
className="hover:opacity-60 ml-1"
title="Quitar"
>
<X className="h-3 w-3" />
</button>
</span>
);
})}
</div>
</td>
<td className="py-3 text-right">
<Button size="sm" variant="outline" onClick={() => { setPickedUserId(s.id); setSearchQ(s.email); setAddOpen(true); }}>
Agregar rol
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Shield className="h-4 w-4" />
Descripción de roles
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-3 md:grid-cols-2">
{ALL_ROLES.map(r => {
const m = ROLE_META[r];
const Icon = m.icon;
return (
<div key={r} className={`rounded border p-3 ${m.color}`}>
<div className="flex items-center gap-2 font-medium mb-1">
<Icon className="h-4 w-4" />
{m.label}
</div>
<p className="text-xs opacity-80">{m.desc}</p>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Add staff dialog */}
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Agregar rol de staff</DialogTitle>
<DialogDescription>
Busca al usuario por email o nombre y asígnale un rol de plataforma.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1">
<Label>Buscar usuario</Label>
<div className="relative">
<Search className="h-4 w-4 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchQ}
onChange={e => { setSearchQ(e.target.value); setPickedUserId(null); }}
placeholder="email o nombre (min 2 caracteres)"
className="pl-8"
/>
</div>
{searchQ.length >= 2 && candidates.length > 0 && !pickedUserId && (
<div className="border rounded mt-1 max-h-40 overflow-auto">
{candidates.map(c => (
<button
type="button"
key={c.id}
onClick={() => { setPickedUserId(c.id); setSearchQ(c.email); }}
className="w-full text-left px-3 py-2 hover:bg-muted text-sm border-b last:border-0"
>
<div className="font-medium">{c.nombre}</div>
<div className="text-xs text-muted-foreground">{c.email} · {c.tenant?.rfc || '—'}</div>
</button>
))}
</div>
)}
</div>
<div className="space-y-2">
<Label>Rol a asignar</Label>
<div className="grid gap-2">
{ALL_ROLES.map(r => {
const m = ROLE_META[r];
const Icon = m.icon;
return (
<button
type="button"
key={r}
onClick={() => setPickedRole(r)}
className={`text-left rounded border-2 p-3 transition-all ${pickedRole === r ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/40'}`}
>
<div className="flex items-center gap-2 font-medium text-sm mb-0.5">
<Icon className="h-4 w-4" />
{m.label}
</div>
<p className="text-xs text-muted-foreground">{m.desc}</p>
</button>
);
})}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddOpen(false)}>Cancelar</Button>
<Button onClick={handleGrant} disabled={!pickedUserId || grantRole.isPending}>
{grantRole.isPending ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : null}
Asignar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</main>
</>
);
}

View File

@@ -0,0 +1,311 @@
'use client';
import { useState, useEffect } from 'react';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui';
import { useAllUsuarios, useUpdateUsuarioGlobal, useDeleteUsuarioGlobal } from '@/lib/hooks/use-usuarios';
import { getTenants, type Tenant } from '@/lib/api/tenants';
import { useAuthStore } from '@/stores/auth-store';
import { Users, Pencil, Trash2, Shield, Eye, Calculator, Building2, X, Check, UserCog, UserCheck, User, Briefcase } from 'lucide-react';
import { cn } from '@horux/shared-ui';
// Mapa de roles + fallback defensivo. El fork despacho introduce roles
// adicionales (cfo, supervisor, auxiliar, cliente) que no estaban en
// Horux 360 single-tenant; si llega un rol no mapeado (ej. uno nuevo
// agregado en BD sin tocar este archivo), `defaultRoleInfo` previene
// runtime errors al hacer `roleInfo.icon`.
const roleLabels: Record<string, { label: string; icon: typeof Shield; color: string }> = {
owner: { label: 'Dueño', icon: Shield, color: 'text-primary' },
cfo: { label: 'CFO', icon: Briefcase, color: 'text-indigo-600' },
contador: { label: 'Contador', icon: Calculator, color: 'text-green-600' },
supervisor: { label: 'Supervisor', icon: UserCheck, color: 'text-blue-600' },
auxiliar: { label: 'Auxiliar', icon: UserCog, color: 'text-cyan-600' },
cliente: { label: 'Cliente', icon: User, color: 'text-amber-600' },
visor: { label: 'Visor', icon: Eye, color: 'text-muted-foreground' },
};
const defaultRoleInfo = { label: 'Sin rol', icon: User, color: 'text-muted-foreground' };
interface EditingUser {
id: string;
nombre: string;
role: 'owner' | 'contador' | 'visor';
tenantId: string;
}
export default function AdminUsuariosPage() {
const { user: currentUser } = useAuthStore();
const { data: usuarios, isLoading, error } = useAllUsuarios();
const updateUsuario = useUpdateUsuarioGlobal();
const deleteUsuario = useDeleteUsuarioGlobal();
const [tenants, setTenants] = useState<Tenant[]>([]);
const [editingUser, setEditingUser] = useState<EditingUser | null>(null);
const [filterTenant, setFilterTenant] = useState<string>('all');
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
getTenants().then(setTenants).catch(console.error);
}, []);
const handleEdit = (usuario: any) => {
setEditingUser({
id: usuario.id,
nombre: usuario.nombre,
role: usuario.role,
tenantId: usuario.tenantId,
});
};
const handleSave = async () => {
if (!editingUser) return;
try {
await updateUsuario.mutateAsync({
id: editingUser.id,
data: {
nombre: editingUser.nombre,
role: editingUser.role,
tenantId: editingUser.tenantId,
},
});
setEditingUser(null);
} catch (err: any) {
alert(err.response?.data?.error || 'Error al actualizar usuario');
}
};
const handleDelete = async (id: string) => {
if (!confirm('Estas seguro de eliminar este usuario?')) return;
try {
await deleteUsuario.mutateAsync(id);
} catch (err: any) {
alert(err.response?.data?.error || 'Error al eliminar usuario');
}
};
const filteredUsuarios = usuarios?.filter(u => {
const matchesTenant = filterTenant === 'all' || u.tenantId === filterTenant;
const matchesSearch = !searchTerm ||
u.nombre.toLowerCase().includes(searchTerm.toLowerCase()) ||
u.email.toLowerCase().includes(searchTerm.toLowerCase());
return matchesTenant && matchesSearch;
});
// Agrupar por empresa
const groupedByTenant = filteredUsuarios?.reduce((acc, u) => {
const key = u.tenantId || 'sin-empresa';
if (!acc[key]) {
acc[key] = {
tenantName: u.tenantName || 'Sin empresa',
users: [],
};
}
acc[key].users.push(u);
return acc;
}, {} as Record<string, { tenantName: string; users: typeof filteredUsuarios }>);
if (error) {
return (
<DashboardShell title="Administracion de Usuarios">
<Card>
<CardContent className="py-8 text-center">
<p className="text-destructive">
No tienes permisos para ver esta pagina o ocurrio un error.
</p>
</CardContent>
</Card>
</DashboardShell>
);
}
return (
<DashboardShell title="Administracion de Usuarios">
<div className="space-y-4">
{/* Filtros */}
<Card>
<CardContent className="py-4">
<div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px]">
<Input
placeholder="Buscar por nombre o email..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="w-[250px]">
<Select value={filterTenant} onValueChange={setFilterTenant}>
<SelectTrigger>
<SelectValue placeholder="Filtrar por empresa" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas las empresas</SelectItem>
{tenants.map(t => (
<SelectItem key={t.id} value={t.id}>{t.nombre}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Stats */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Users className="h-5 w-5" />
<span className="font-medium">{filteredUsuarios?.length || 0} usuarios</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Building2 className="h-4 w-4" />
<span className="text-sm">{Object.keys(groupedByTenant || {}).length} empresas</span>
</div>
</div>
{/* Users by tenant */}
{isLoading ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
Cargando usuarios...
</CardContent>
</Card>
) : (
Object.entries(groupedByTenant || {}).map(([tenantId, { tenantName, users }]) => (
<Card key={tenantId}>
<CardHeader className="py-3">
<CardTitle className="text-base flex items-center gap-2">
<Building2 className="h-4 w-4" />
{tenantName}
<span className="text-muted-foreground font-normal text-sm">
({users?.length} usuarios)
</span>
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="divide-y">
{users?.map(usuario => {
const roleInfo = roleLabels[usuario.role] || defaultRoleInfo;
const RoleIcon = roleInfo.icon;
const isCurrentUser = usuario.id === currentUser?.id;
const isEditing = editingUser?.id === usuario.id;
return (
<div key={usuario.id} className="p-4 flex items-center justify-between">
<div className="flex items-center gap-4 flex-1">
<div className={cn(
'w-10 h-10 rounded-full flex items-center justify-center',
'bg-primary/10 text-primary font-medium'
)}>
{usuario.nombre.charAt(0).toUpperCase()}
</div>
<div className="flex-1">
{isEditing ? (
<div className="space-y-2">
<Input
value={editingUser.nombre}
onChange={(e) => setEditingUser({ ...editingUser, nombre: e.target.value })}
className="h-8"
/>
<div className="flex gap-2">
<Select
value={editingUser.role}
onValueChange={(v) => setEditingUser({ ...editingUser, role: v as any })}
>
<SelectTrigger className="h-8 w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="owner">Dueño</SelectItem>
<SelectItem value="contador">Contador</SelectItem>
<SelectItem value="visor">Visor</SelectItem>
</SelectContent>
</Select>
<Select
value={editingUser.tenantId}
onValueChange={(v) => setEditingUser({ ...editingUser, tenantId: v })}
>
<SelectTrigger className="h-8 flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{tenants.map(t => (
<SelectItem key={t.id} value={t.id}>{t.nombre}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
) : (
<>
<div className="flex items-center gap-2">
<span className="font-medium">{usuario.nombre}</span>
{isCurrentUser && (
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded">Tu</span>
)}
{!usuario.active && (
<span className="text-xs bg-destructive/10 text-destructive px-2 py-0.5 rounded">Inactivo</span>
)}
</div>
<div className="text-sm text-muted-foreground">{usuario.email}</div>
</>
)}
</div>
</div>
<div className="flex items-center gap-4">
{!isEditing && (
<div className={cn('flex items-center gap-1', roleInfo.color)}>
<RoleIcon className="h-4 w-4" />
<span className="text-sm">{roleInfo.label}</span>
</div>
)}
{!isCurrentUser && (
<div className="flex gap-1">
{isEditing ? (
<>
<Button
variant="ghost"
size="icon"
onClick={handleSave}
disabled={updateUsuario.isPending}
>
<Check className="h-4 w-4 text-green-600" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setEditingUser(null)}
>
<X className="h-4 w-4" />
</Button>
</>
) : (
<>
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(usuario)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(usuario.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
))
)}
</div>
</DashboardShell>
);
}

View File

@@ -0,0 +1,126 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, Button, SortableHeader } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { formatCurrency } from '@/lib/utils';
import { exportToExcel } from '@/lib/export-excel';
import { useTableSort } from '@horux/shared-ui';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
import { Eye, Download } from 'lucide-react';
import type { Cfdi } from '@horux/shared';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
const EXCEL_COLUMNS = [
{ header: 'UUID', key: 'uuid', width: 40 },
{ header: 'Fecha Emision', key: '_fechaEmision', width: 15 },
{ header: 'Fecha Cancelacion', key: '_fechaCancelacion', width: 18 },
{ header: 'RFC Emisor', key: 'rfcEmisor', width: 15 },
{ header: 'Nombre Emisor', key: 'nombreEmisor', width: 30 },
{ header: 'RFC Receptor', key: 'rfcReceptor', width: 15 },
{ header: 'Nombre Receptor', key: 'nombreReceptor', width: 30 },
{ header: 'Total MXN', key: '_totalMxn', width: 15 },
];
function prepareRows(data: any[]) {
return data.map((c) => ({
...c,
_fechaEmision: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
_fechaCancelacion: c.fechaCancelacion ? new Date(c.fechaCancelacion).toLocaleDateString('es-MX') : '',
_totalMxn: Number(c.totalMxn || 0),
}));
}
export default function CancelacionesPeriodoAnteriorPage() {
const [selectedCfdi, setSelectedCfdi] = useState<Cfdi | null>(null);
const { selectedContribuyenteId } = useContribuyenteStore();
const { data, isLoading } = useQuery({
queryKey: ['drilldown-cancelaciones-periodo-anterior', selectedContribuyenteId],
queryFn: async () => {
const params = new URLSearchParams();
if (selectedContribuyenteId) params.set('contribuyenteId', selectedContribuyenteId);
const res = await apiClient.get<Cfdi[]>(`/alertas/drilldown/cancelaciones-periodo-anterior?${params}`);
return res.data;
},
});
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'cancelacion' | 'total'>(
data,
{
fecha: (c) => new Date(c.fechaEmision).getTime(),
cancelacion: (c: any) => c.fechaCancelacion ? new Date(c.fechaCancelacion).getTime() : 0,
total: (c) => Number(c.totalMxn || 0),
},
'cancelacion',
);
const handleExport = () => {
if (!sortedData || sortedData.length === 0) return;
exportToExcel(prepareRows(sortedData), EXCEL_COLUMNS, 'cancelaciones-periodo-anterior');
};
return (
<DashboardShell title="Cancelaciones de Periodo Anterior">
<Card>
<CardContent className="pt-6">
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : !data || data.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">No hay CFDIs cancelados de periodos anteriores</div>
) : (
<div className="overflow-x-auto">
<div className="flex items-center justify-between mb-4">
<p className="text-xs text-muted-foreground">
{data.length} CFDIs emitidos en meses anteriores y cancelados este mes
</p>
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="h-4 w-4 mr-1" />
Excel
</Button>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-3 font-medium">UUID</th>
<SortableHeader label="Fecha Emision" active={getSortIndicator('fecha')} onClick={() => toggleSort('fecha')} />
<SortableHeader label="Fecha Cancelacion" active={getSortIndicator('cancelacion')} onClick={() => toggleSort('cancelacion')} />
<th className="pb-3 font-medium">RFC Emisor</th>
<th className="pb-3 font-medium">RFC Receptor</th>
<SortableHeader label="Total MXN" align="right" active={getSortIndicator('total')} onClick={() => toggleSort('total')} />
<th className="pb-3 font-medium"></th>
</tr>
</thead>
<tbody>
{(sortedData || []).map((cfdi: any) => (
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
<td className="py-3 font-mono text-xs">{cfdi.uuid?.substring(0, 8)}</td>
<td className="py-3">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-3">{cfdi.fechaCancelacion ? new Date(cfdi.fechaCancelacion).toLocaleDateString('es-MX') : '-'}</td>
<td className="py-3 font-mono text-xs">{cfdi.rfcEmisor}</td>
<td className="py-3 font-mono text-xs">{cfdi.rfcReceptor}</td>
<td className="py-3 text-right font-medium">{formatCurrency(Number(cfdi.totalMxn))}</td>
<td className="py-3">
<Button variant="ghost" size="sm" onClick={() => setSelectedCfdi(cfdi)} title="Ver factura">
<Eye className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
<CfdiViewerModal
cfdi={selectedCfdi}
open={!!selectedCfdi}
onClose={() => setSelectedCfdi(null)}
/>
</DashboardShell>
);
}

View File

@@ -0,0 +1,120 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, Button, SortableHeader } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { formatCurrency } from '@/lib/utils';
import { exportToExcel } from '@/lib/export-excel';
import { useTableSort } from '@horux/shared-ui';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
import { Eye, Download } from 'lucide-react';
import type { Cfdi } from '@horux/shared';
const EXCEL_COLUMNS = [
{ header: 'UUID', key: 'uuid', width: 40 },
{ header: 'Fecha Emision', key: '_fechaEmision', width: 15 },
{ header: 'Fecha Cancelacion', key: '_fechaCancelacion', width: 18 },
{ header: 'RFC Emisor', key: 'rfcEmisor', width: 15 },
{ header: 'Nombre Emisor', key: 'nombreEmisor', width: 30 },
{ header: 'RFC Receptor', key: 'rfcReceptor', width: 15 },
{ header: 'Nombre Receptor', key: 'nombreReceptor', width: 30 },
{ header: 'Total MXN', key: '_totalMxn', width: 15 },
];
function prepareRows(data: any[]) {
return data.map((c) => ({
...c,
_fechaEmision: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
_fechaCancelacion: c.fechaCancelacion ? new Date(c.fechaCancelacion).toLocaleDateString('es-MX') : '',
_totalMxn: Number(c.totalMxn || 0),
}));
}
export default function CancelacionesPage() {
const [selectedCfdi, setSelectedCfdi] = useState<Cfdi | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['drilldown-cancelaciones'],
queryFn: async () => {
const res = await apiClient.get<Cfdi[]>('/alertas/drilldown/cancelaciones');
return res.data;
},
});
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'cancelacion' | 'total'>(
data,
{
fecha: (c) => new Date(c.fechaEmision).getTime(),
cancelacion: (c: any) => c.fechaCancelacion ? new Date(c.fechaCancelacion).getTime() : 0,
total: (c) => Number(c.totalMxn || 0),
},
'cancelacion',
);
const handleExport = () => {
if (!sortedData || sortedData.length === 0) return;
exportToExcel(prepareRows(sortedData), EXCEL_COLUMNS, 'cfdis-cancelados');
};
return (
<DashboardShell title="CFDIs Cancelados (Ultimos 5 años)">
<Card>
<CardContent className="pt-6">
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : !data || data.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">No hay CFDIs cancelados</div>
) : (
<div className="overflow-x-auto">
<div className="flex items-center justify-between mb-4">
<p className="text-xs text-muted-foreground">{data.length} CFDIs cancelados</p>
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="h-4 w-4 mr-1" />
Excel
</Button>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-3 font-medium">UUID</th>
<SortableHeader label="Fecha Emision" active={getSortIndicator('fecha')} onClick={() => toggleSort('fecha')} />
<SortableHeader label="Fecha Cancelacion" active={getSortIndicator('cancelacion')} onClick={() => toggleSort('cancelacion')} />
<th className="pb-3 font-medium">RFC Emisor</th>
<th className="pb-3 font-medium">RFC Receptor</th>
<SortableHeader label="Total MXN" align="right" active={getSortIndicator('total')} onClick={() => toggleSort('total')} />
<th className="pb-3 font-medium"></th>
</tr>
</thead>
<tbody>
{(sortedData || []).map((cfdi: any) => (
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
<td className="py-3 font-mono text-xs">{cfdi.uuid?.substring(0, 8)}</td>
<td className="py-3">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-3">{cfdi.fechaCancelacion ? new Date(cfdi.fechaCancelacion).toLocaleDateString('es-MX') : '-'}</td>
<td className="py-3 font-mono text-xs">{cfdi.rfcEmisor}</td>
<td className="py-3 font-mono text-xs">{cfdi.rfcReceptor}</td>
<td className="py-3 text-right font-medium">{formatCurrency(Number(cfdi.totalMxn))}</td>
<td className="py-3">
<Button variant="ghost" size="sm" onClick={() => setSelectedCfdi(cfdi)} title="Ver factura">
<Eye className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
<CfdiViewerModal
cfdi={selectedCfdi}
open={!!selectedCfdi}
onClose={() => setSelectedCfdi(null)}
/>
</DashboardShell>
);
}

View File

@@ -0,0 +1,79 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle, SortableHeader } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { formatCurrency } from '@/lib/utils';
import { useTableSort } from '@horux/shared-ui';
export default function ConcentracionClientesPage() {
const { data, isLoading } = useQuery({
queryKey: ['drilldown-concentracion-clientes'],
queryFn: async () => {
const res = await apiClient.get<any[]>('/alertas/drilldown/concentracion-clientes');
return res.data;
},
});
const { sortedData, toggleSort, getSortIndicator } = useTableSort<any, 'cfdis' | 'total'>(
data,
{
cfdis: (d) => Number(d.cantidad || 0),
total: (d) => Number(d.total || 0),
},
'total',
);
return (
<DashboardShell title="Concentracion de Clientes">
<Card>
<CardHeader>
<CardTitle className="text-base">Participacion por Cliente (Facturas Emitidas)</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : !data || data.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">No hay datos</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-3 font-medium">RFC</th>
<th className="pb-3 font-medium">Nombre</th>
<SortableHeader label="CFDIs" align="right" active={getSortIndicator('cfdis')} onClick={() => toggleSort('cfdis')} />
<SortableHeader label="Total Facturado" align="right" active={getSortIndicator('total')} onClick={() => toggleSort('total')} />
<th className="pb-3 font-medium text-right">Participacion</th>
</tr>
</thead>
<tbody>
{(sortedData || []).map((d: any) => (
<tr key={d.rfc} className="border-b hover:bg-muted/50">
<td className="py-3 font-mono text-xs">{d.rfc}</td>
<td className="py-3 truncate max-w-[200px]">{d.nombre}</td>
<td className="py-3 text-right">{d.cantidad}</td>
<td className="py-3 text-right font-medium">{formatCurrency(d.total)}</td>
<td className="py-3 text-right">
<div className="flex items-center justify-end gap-2">
<div className="w-16 bg-muted rounded-full h-2">
<div
className="bg-primary rounded-full h-2"
style={{ width: `${Math.min(d.participacion, 100)}%` }}
/>
</div>
<span className="font-medium w-14 text-right">{d.participacion}%</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</DashboardShell>
);
}

View File

@@ -0,0 +1,79 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle, SortableHeader } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { formatCurrency } from '@/lib/utils';
import { useTableSort } from '@horux/shared-ui';
export default function ConcentracionProveedoresPage() {
const { data, isLoading } = useQuery({
queryKey: ['drilldown-concentracion-proveedores'],
queryFn: async () => {
const res = await apiClient.get<any[]>('/alertas/drilldown/concentracion-proveedores');
return res.data;
},
});
const { sortedData, toggleSort, getSortIndicator } = useTableSort<any, 'cfdis' | 'total'>(
data,
{
cfdis: (d) => Number(d.cantidad || 0),
total: (d) => Number(d.total || 0),
},
'total',
);
return (
<DashboardShell title="Concentracion de Proveedores">
<Card>
<CardHeader>
<CardTitle className="text-base">Participacion por Proveedor (Facturas Recibidas)</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : !data || data.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">No hay datos</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-3 font-medium">RFC</th>
<th className="pb-3 font-medium">Nombre</th>
<SortableHeader label="CFDIs" align="right" active={getSortIndicator('cfdis')} onClick={() => toggleSort('cfdis')} />
<SortableHeader label="Total Facturado" align="right" active={getSortIndicator('total')} onClick={() => toggleSort('total')} />
<th className="pb-3 font-medium text-right">Participacion</th>
</tr>
</thead>
<tbody>
{(sortedData || []).map((d: any) => (
<tr key={d.rfc} className="border-b hover:bg-muted/50">
<td className="py-3 font-mono text-xs">{d.rfc}</td>
<td className="py-3 truncate max-w-[200px]">{d.nombre}</td>
<td className="py-3 text-right">{d.cantidad}</td>
<td className="py-3 text-right font-medium">{formatCurrency(d.total)}</td>
<td className="py-3 text-right">
<div className="flex items-center justify-end gap-2">
<div className="w-16 bg-muted rounded-full h-2">
<div
className="bg-primary rounded-full h-2"
style={{ width: `${Math.min(d.participacion, 100)}%` }}
/>
</div>
<span className="font-medium w-14 text-right">{d.participacion}%</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</DashboardShell>
);
}

View File

@@ -0,0 +1,344 @@
'use client';
import { useState, useMemo } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle, Button, SortableHeader, Input } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { formatCurrency } from '@/lib/utils';
import { exportToExcel } from '@/lib/export-excel';
import { useTableSort } from '@horux/shared-ui';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
import { Eye, Download, CheckSquare, Square, EyeOff, Filter, RotateCcw } from 'lucide-react';
import type { Cfdi } from '@horux/shared';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
const TIPO_ALERTA = 'discrepancia-regimen';
const EXCEL_COLUMNS = [
{ header: 'UUID', key: 'uuid', width: 40 },
{ header: 'Fecha', key: '_fecha', width: 15 },
{ header: 'RFC Emisor', key: 'rfcEmisor', width: 15 },
{ header: 'Nombre Emisor', key: 'nombreEmisor', width: 30 },
{ header: 'RFC Receptor', key: 'rfcReceptor', width: 15 },
{ header: 'Nombre Receptor', key: 'nombreReceptor', width: 30 },
{ header: 'Regimen Receptor', key: 'regimenReceptor', width: 18 },
{ header: 'Total MXN', key: '_totalMxn', width: 15 },
];
function prepareRows(data: any[]) {
return data.map((c) => ({
...c,
_fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
_totalMxn: Number(c.totalMxn || 0),
regimenReceptor: c.regimenReceptor || c.regimenFiscalReceptor || '',
}));
}
export default function DiscrepanciaRegimenPage() {
const [selectedCfdi, setSelectedCfdi] = useState<Cfdi | null>(null);
const [checked, setChecked] = useState<Set<string>>(new Set());
const [view, setView] = useState<'activos' | 'descartados'>('activos');
const { selectedContribuyenteId } = useContribuyenteStore();
const queryClient = useQueryClient();
// Filters
const [fechaDesde, setFechaDesde] = useState('');
const [fechaHasta, setFechaHasta] = useState('');
const [regimenFilter, setRegimenFilter] = useState<string>('');
// Activos (lo que aparece en la alerta)
const activosQ = useQuery({
queryKey: ['drilldown-discrepancia', selectedContribuyenteId],
queryFn: async () => {
const params = new URLSearchParams();
if (selectedContribuyenteId) params.set('contribuyenteId', selectedContribuyenteId);
const res = await apiClient.get<Cfdi[]>(`/alertas/drilldown/discrepancia-regimen?${params}`);
return res.data;
},
enabled: view === 'activos',
});
// Descartados (lo que ya se marcó para ignorar)
const descartadosQ = useQuery({
queryKey: ['descartados-discrepancia', selectedContribuyenteId],
queryFn: async () => {
const params = new URLSearchParams({ tipoAlerta: TIPO_ALERTA });
if (selectedContribuyenteId) params.set('contribuyenteId', selectedContribuyenteId);
const res = await apiClient.get<{ data: Cfdi[] }>(`/alertas/descartados?${params}`);
return res.data.data;
},
enabled: view === 'descartados',
});
const data = view === 'activos' ? activosQ.data : descartadosQ.data;
const isLoading = view === 'activos' ? activosQ.isLoading : descartadosQ.isLoading;
// Extract unique regímenes for the filter dropdown
const regimenesUnicos = useMemo(() => {
if (!data) return [];
const set = new Set<string>();
data.forEach((c: any) => {
const reg = c.regimenReceptor || c.regimenFiscalReceptor;
if (reg) set.add(reg);
});
return [...set].sort();
}, [data]);
// Apply filters: fecha + regimen (descartados already excluded by backend)
const visibleData = useMemo(() => {
if (!data) return [];
let filtered = data;
if (fechaDesde) {
filtered = filtered.filter(c => c.fechaEmision >= fechaDesde);
}
if (fechaHasta) {
filtered = filtered.filter(c => c.fechaEmision <= fechaHasta + 'T23:59:59');
}
if (regimenFilter) {
filtered = filtered.filter((c: any) => (c.regimenReceptor || c.regimenFiscalReceptor) === regimenFilter);
}
return filtered;
}, [data, fechaDesde, fechaHasta, regimenFilter]);
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'total'>(
visibleData,
{
fecha: (c) => new Date(c.fechaEmision).getTime(),
total: (c) => Number(c.totalMxn || 0),
},
'fecha',
);
const handleExport = () => {
if (!sortedData || sortedData.length === 0) return;
exportToExcel(prepareRows(sortedData), EXCEL_COLUMNS, 'cfdis-discrepancia-regimen');
};
const toggleCheck = (id: string) => {
setChecked(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
const toggleSelectAll = () => {
if (!sortedData) return;
if (checked.size === sortedData.length) {
setChecked(new Set());
} else {
setChecked(new Set(sortedData.map(c => String(c.id))));
}
};
const invalidateAll = () => {
queryClient.invalidateQueries({ queryKey: ['drilldown-discrepancia'] });
queryClient.invalidateQueries({ queryKey: ['descartados-discrepancia'] });
queryClient.invalidateQueries({ queryKey: ['alertas-automaticas'] });
queryClient.invalidateQueries({ queryKey: ['alertas'] });
};
const handleDescartar = async () => {
const cfdiIds = [...checked].map(id => Number(id));
try {
await apiClient.post('/alertas/descartar', { cfdiIds, tipoAlerta: TIPO_ALERTA });
setChecked(new Set());
invalidateAll();
} catch {
alert('Error al descartar');
}
};
const handleRestaurar = async () => {
const cfdiIds = [...checked].map(id => Number(id));
try {
await apiClient.delete('/alertas/descartar', { data: { cfdiIds, tipoAlerta: TIPO_ALERTA } });
setChecked(new Set());
invalidateAll();
} catch {
alert('Error al restaurar');
}
};
const handleChangeView = (next: 'activos' | 'descartados') => {
setView(next);
setChecked(new Set());
};
const handleClearFilters = () => {
setFechaDesde('');
setFechaHasta('');
setRegimenFilter('');
};
const hasActiveFilters = fechaDesde || fechaHasta || regimenFilter;
const allChecked = sortedData && sortedData.length > 0 &&
checked.size === sortedData.length;
return (
<DashboardShell title="CFDIs con Discrepancia de Régimen">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-base">
{view === 'activos'
? 'Facturas recibidas con régimen fiscal que no coincide con los regímenes activos'
: 'CFDIs descartados manualmente — ignorados en la alerta'}
</CardTitle>
<div className="flex items-center gap-2">
{/* Toggle Activos / Descartados */}
<div className="flex rounded-md border bg-background p-0.5 text-sm">
<button
type="button"
onClick={() => handleChangeView('activos')}
className={`px-3 py-1 rounded ${view === 'activos' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
Activos
</button>
<button
type="button"
onClick={() => handleChangeView('descartados')}
className={`px-3 py-1 rounded ${view === 'descartados' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
Descartados
</button>
</div>
{checked.size > 0 && view === 'activos' && (
<Button variant="outline" size="sm" onClick={handleDescartar}>
<EyeOff className="h-4 w-4 mr-1" />
Descartar ({checked.size})
</Button>
)}
{checked.size > 0 && view === 'descartados' && (
<Button variant="outline" size="sm" onClick={handleRestaurar}>
<RotateCcw className="h-4 w-4 mr-1" />
Restaurar ({checked.size})
</Button>
)}
{data && data.length > 0 && (
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="h-4 w-4 mr-1" />
Excel
</Button>
)}
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap items-end gap-3 mt-3 pt-3 border-t">
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Filter className="h-4 w-4" />
Filtros:
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">Desde</label>
<Input
type="date"
value={fechaDesde}
onChange={e => setFechaDesde(e.target.value)}
className="h-8 w-[150px] text-sm"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">Hasta</label>
<Input
type="date"
value={fechaHasta}
onChange={e => setFechaHasta(e.target.value)}
className="h-8 w-[150px] text-sm"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">Régimen</label>
<select
value={regimenFilter}
onChange={e => setRegimenFilter(e.target.value)}
className="h-8 rounded-md border border-input bg-background px-2 text-sm"
>
<option value="">Todos</option>
{regimenesUnicos.map(r => (
<option key={r} value={r}>{r}</option>
))}
</select>
</div>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={handleClearFilters} className="h-8 text-xs">
Limpiar
</Button>
)}
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : !sortedData || sortedData.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{hasActiveFilters
? 'No hay resultados con los filtros seleccionados'
: view === 'activos'
? 'No hay discrepancias nuevas'
: 'No hay CFDIs descartados'}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-3 w-8">
<button onClick={toggleSelectAll} className="hover:text-foreground transition-colors">
{allChecked ? <CheckSquare className="h-4 w-4 text-primary" /> : <Square className="h-4 w-4" />}
</button>
</th>
<th className="pb-3 font-medium">UUID</th>
<SortableHeader label="Fecha" active={getSortIndicator('fecha')} onClick={() => toggleSort('fecha')} />
<th className="pb-3 font-medium">RFC Emisor</th>
<th className="pb-3 font-medium">Nombre Emisor</th>
<th className="pb-3 font-medium">Régimen Receptor</th>
<SortableHeader label="Total MXN" align="right" active={getSortIndicator('total')} onClick={() => toggleSort('total')} />
<th className="pb-3 font-medium"></th>
</tr>
</thead>
<tbody>
{sortedData.map((cfdi: any) => (
<tr key={cfdi.id} className={`border-b hover:bg-muted/50 ${checked.has(cfdi.id) ? 'bg-primary/5' : ''}`}>
<td className="py-3">
<button onClick={() => toggleCheck(cfdi.id)} className="hover:text-primary transition-colors">
{checked.has(cfdi.id) ? <CheckSquare className="h-4 w-4 text-primary" /> : <Square className="h-4 w-4 text-muted-foreground" />}
</button>
</td>
<td className="py-3 font-mono text-xs">{cfdi.uuid?.substring(0, 8)}</td>
<td className="py-3">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-3 font-mono text-xs">{cfdi.rfcEmisor}</td>
<td className="py-3 truncate max-w-[200px]">{cfdi.nombreEmisor}</td>
<td className="py-3 font-mono font-bold text-destructive">{cfdi.regimenReceptor}</td>
<td className="py-3 text-right font-medium">{formatCurrency(Number(cfdi.totalMxn))}</td>
<td className="py-3">
<Button variant="ghost" size="sm" onClick={() => setSelectedCfdi(cfdi)} title="Ver factura">
<Eye className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
<p className="text-xs text-muted-foreground mt-4">
{sortedData.length} CFDI{sortedData.length !== 1 ? 's' : ''} {view === 'activos' ? 'con discrepancia' : 'descartados'}
{hasActiveFilters && data && ` (de ${data.length} total)`}
</p>
</div>
)}
</CardContent>
</Card>
<CfdiViewerModal
cfdi={selectedCfdi}
open={!!selectedCfdi}
onClose={() => setSelectedCfdi(null)}
/>
</DashboardShell>
);
}

View File

@@ -0,0 +1,118 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, Button, SortableHeader } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { formatCurrency } from '@/lib/utils';
import { exportToExcel } from '@/lib/export-excel';
import { useTableSort } from '@horux/shared-ui';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
import { Eye, Download } from 'lucide-react';
import type { Cfdi } from '@horux/shared';
const EXCEL_COLUMNS = [
{ header: 'UUID', key: 'uuid', width: 40 },
{ header: 'Fecha', key: '_fecha', width: 15 },
{ header: 'RFC Emisor', key: 'rfcEmisor', width: 15 },
{ header: 'Nombre Emisor', key: 'nombreEmisor', width: 30 },
{ header: 'RFC Receptor', key: 'rfcReceptor', width: 15 },
{ header: 'Nombre Receptor', key: 'nombreReceptor', width: 30 },
{ header: 'Total MXN', key: '_totalMxn', width: 15 },
{ header: 'Forma Pago', key: 'formaPago', width: 12 },
];
function prepareRows(data: any[]) {
return data.map((c) => ({
...c,
_fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
_totalMxn: Number(c.totalMxn || 0),
}));
}
export default function EfectivoPage() {
const [selectedCfdi, setSelectedCfdi] = useState<Cfdi | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['drilldown-efectivo'],
queryFn: async () => {
const res = await apiClient.get<Cfdi[]>('/alertas/drilldown/efectivo');
return res.data;
},
});
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'total'>(
data,
{
fecha: (c) => new Date(c.fechaEmision).getTime(),
total: (c) => Number(c.totalMxn || 0),
},
'fecha',
);
const handleExport = () => {
if (!sortedData || sortedData.length === 0) return;
exportToExcel(prepareRows(sortedData), EXCEL_COLUMNS, 'cfdis-pago-efectivo');
};
return (
<DashboardShell title="CFDIs con Pago en Efectivo">
<Card>
<CardContent className="pt-6">
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : !data || data.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">No hay CFDIs con pago en efectivo</div>
) : (
<div className="overflow-x-auto">
<div className="flex items-center justify-between mb-4">
<p className="text-xs text-muted-foreground">{data.length} CFDIs con pago en efectivo</p>
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="h-4 w-4 mr-1" />
Excel
</Button>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-3 font-medium">UUID</th>
<SortableHeader label="Fecha" active={getSortIndicator('fecha')} onClick={() => toggleSort('fecha')} />
<th className="pb-3 font-medium">RFC Emisor</th>
<th className="pb-3 font-medium">Nombre Emisor</th>
<th className="pb-3 font-medium">RFC Receptor</th>
<SortableHeader label="Total MXN" align="right" active={getSortIndicator('total')} onClick={() => toggleSort('total')} />
<th className="pb-3 font-medium"></th>
</tr>
</thead>
<tbody>
{(sortedData || []).map((cfdi: any) => (
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
<td className="py-3 font-mono text-xs">{cfdi.uuid?.substring(0, 8)}</td>
<td className="py-3">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-3 font-mono text-xs">{cfdi.rfcEmisor}</td>
<td className="py-3 truncate max-w-[200px]">{cfdi.nombreEmisor}</td>
<td className="py-3 font-mono text-xs">{cfdi.rfcReceptor}</td>
<td className="py-3 text-right font-medium">{formatCurrency(Number(cfdi.totalMxn))}</td>
<td className="py-3">
<Button variant="ghost" size="sm" onClick={() => setSelectedCfdi(cfdi)} title="Ver factura">
<Eye className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
<CfdiViewerModal
cfdi={selectedCfdi}
open={!!selectedCfdi}
onClose={() => setSelectedCfdi(null)}
/>
</DashboardShell>
);
}

View File

@@ -0,0 +1,63 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { formatCurrency } from '@/lib/utils';
export default function ListaNegraClientesPage() {
const { data, isLoading } = useQuery({
queryKey: ['drilldown-lista-negra-clientes'],
queryFn: async () => {
const res = await apiClient.get<any[]>('/alertas/drilldown/lista-negra-clientes');
return res.data;
},
});
return (
<DashboardShell title="Clientes en Lista Negra del SAT">
<Card>
<CardHeader>
<CardTitle className="text-base">Clientes a los que has facturado que aparecen en la lista del Art. 69-B</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : !data || data.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">Ningun cliente en lista negra</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-3 font-medium">RFC</th>
<th className="pb-3 font-medium">Nombre</th>
<th className="pb-3 font-medium">Situacion SAT</th>
<th className="pb-3 font-medium text-right">CFDIs</th>
<th className="pb-3 font-medium text-right">Total Facturado</th>
</tr>
</thead>
<tbody>
{data.map((d: any) => (
<tr key={d.rfc} className="border-b hover:bg-muted/50">
<td className="py-3 font-mono text-xs">{d.rfc}</td>
<td className="py-3 truncate max-w-[200px]">{d.nombre}</td>
<td className="py-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
d.situacionSat === 'Definitivo' ? 'bg-destructive/10 text-destructive' : 'bg-warning/10 text-warning'
}`}>{d.situacionSat}</span>
</td>
<td className="py-3 text-right">{d.cantidad}</td>
<td className="py-3 text-right font-medium">{formatCurrency(d.total)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</DashboardShell>
);
}

View File

@@ -0,0 +1,63 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { formatCurrency } from '@/lib/utils';
export default function ListaNegraProveedoresPage() {
const { data, isLoading } = useQuery({
queryKey: ['drilldown-lista-negra-proveedores'],
queryFn: async () => {
const res = await apiClient.get<any[]>('/alertas/drilldown/lista-negra-proveedores');
return res.data;
},
});
return (
<DashboardShell title="Proveedores en Lista Negra del SAT">
<Card>
<CardHeader>
<CardTitle className="text-base">Proveedores de los que has recibido facturas que aparecen en la lista del Art. 69-B</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : !data || data.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">Ningun proveedor en lista negra</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-3 font-medium">RFC</th>
<th className="pb-3 font-medium">Nombre</th>
<th className="pb-3 font-medium">Situacion SAT</th>
<th className="pb-3 font-medium text-right">CFDIs</th>
<th className="pb-3 font-medium text-right">Total Facturado</th>
</tr>
</thead>
<tbody>
{data.map((d: any) => (
<tr key={d.rfc} className="border-b hover:bg-muted/50">
<td className="py-3 font-mono text-xs">{d.rfc}</td>
<td className="py-3 truncate max-w-[200px]">{d.nombre}</td>
<td className="py-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
d.situacionSat === 'Definitivo' ? 'bg-destructive/10 text-destructive' : 'bg-warning/10 text-warning'
}`}>{d.situacionSat}</span>
</td>
<td className="py-3 text-right">{d.cantidad}</td>
<td className="py-3 text-right font-medium">{formatCurrency(d.total)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</DashboardShell>
);
}

View File

@@ -0,0 +1,241 @@
'use client';
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle, Button } from '@horux/shared-ui';
import { useAlertas, useAlertasStats, useUpdateAlerta, useDeleteAlerta, useMarkAllAsRead } from '@/lib/hooks/use-alertas';
import { apiClient } from '@/lib/api/client';
import { Bell, Check, Trash2, AlertTriangle, Info, AlertCircle, CheckCircle, ShieldAlert, ChevronRight, Clock } from 'lucide-react';
import { cn } from '@horux/shared-ui';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
interface AlertaAuto {
id: string;
tipo: string;
titulo: string;
mensaje: string;
prioridad: 'alta' | 'media' | 'baja';
detalle?: string;
valor?: number;
}
const prioridadStyles = {
alta: 'border-l-4 border-l-destructive bg-destructive/5',
media: 'border-l-4 border-l-warning bg-warning/5',
baja: 'border-l-4 border-l-muted bg-muted/5',
};
const prioridadIcons = {
alta: AlertCircle,
media: AlertTriangle,
baja: Info,
};
export default function AlertasPage() {
const [filter, setFilter] = useState<'todas' | 'pendientes' | 'resueltas'>('pendientes');
const { data: alertas, isLoading } = useAlertas({
resuelta: filter === 'resueltas' ? true : filter === 'pendientes' ? false : undefined,
});
const { data: stats } = useAlertasStats();
const updateAlerta = useUpdateAlerta();
const deleteAlerta = useDeleteAlerta();
const markAllAsRead = useMarkAllAsRead();
const router = useRouter();
const { selectedContribuyenteId } = useContribuyenteStore();
const queryClient = useQueryClient();
const { data: alertasAuto } = useQuery({
queryKey: ['alertas-automaticas', selectedContribuyenteId],
queryFn: async () => {
const params = new URLSearchParams();
if (selectedContribuyenteId) params.set('contribuyenteId', selectedContribuyenteId);
const res = await apiClient.get<AlertaAuto[]>(`/alertas/automaticas?${params}`);
return res.data;
},
});
const { data: alertasManuales } = useQuery({
queryKey: ['alertas-manuales', selectedContribuyenteId],
queryFn: async () => {
const params = new URLSearchParams();
if (selectedContribuyenteId) params.set('contribuyenteId', selectedContribuyenteId);
const res = await apiClient.get<any[]>(`/alertas/manuales?${params}`);
return res.data;
},
});
const handleResolver = async (id: string) => {
await apiClient.patch(`/alertas/manuales/${id}/resolver`);
queryClient.invalidateQueries({ queryKey: ['alertas-manuales'] });
};
const handleMarkAsRead = (id: number) => {
updateAlerta.mutate({ id, data: { leida: true } });
};
const handleResolve = (id: number) => {
updateAlerta.mutate({ id, data: { resuelta: true } });
};
const handleDelete = (id: number) => {
if (confirm('¿Eliminar esta alerta?')) {
deleteAlerta.mutate(id);
}
};
return (
<DashboardShell title="Alertas">
<div className="space-y-4">
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Alertas del Sistema</CardTitle>
<ShieldAlert className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{alertasAuto?.length || 0}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Obligaciones Pendientes</CardTitle>
<Clock className="h-4 w-4 text-warning" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-warning">{alertasManuales?.length || 0}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Total Alertas</CardTitle>
<Bell className="h-4 w-4 text-destructive" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-destructive">
{(alertasAuto?.length || 0) + (alertasManuales?.length || 0)}
</div>
</CardContent>
</Card>
</div>
{/* Alertas Automáticas */}
{alertasAuto && alertasAuto.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ShieldAlert className="h-4 w-4" />
Alertas del Sistema ({alertasAuto.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{alertasAuto.map((alerta) => {
const Icon = alerta.prioridad === 'alta' ? AlertCircle : AlertTriangle;
return (
<div
key={alerta.id}
className={cn(
'p-3 rounded-lg border',
alerta.prioridad === 'alta' && 'border-l-4 border-l-destructive bg-destructive/5',
alerta.prioridad === 'media' && 'border-l-4 border-l-warning bg-warning/5',
alerta.prioridad === 'baja' && 'border-l-4 border-l-muted bg-muted/5',
)}
>
<div className="flex items-start gap-3">
<Icon className={cn(
'h-5 w-5 mt-0.5 flex-shrink-0',
alerta.prioridad === 'alta' && 'text-destructive',
alerta.prioridad === 'media' && 'text-warning',
)} />
<div className="flex-1 min-w-0">
<h4 className="font-medium text-sm">{alerta.titulo}</h4>
<p className="text-xs text-muted-foreground mt-1">{alerta.mensaje}</p>
</div>
</div>
{alerta.detalle && (
<div className="mt-2 flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => router.push(alerta.detalle!)}
>
Ver detalle <ChevronRight className="h-3 w-3 ml-1" />
</Button>
</div>
)}
</div>
);
})}
</CardContent>
</Card>
)}
{/* Obligaciones Fiscales Pendientes */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Clock className="h-4 w-4" />
Obligaciones Fiscales Pendientes ({alertasManuales?.length || 0})
</CardTitle>
</CardHeader>
<CardContent>
{!alertasManuales || alertasManuales.length === 0 ? (
<div className="py-6 text-center text-muted-foreground">
<CheckCircle className="h-10 w-10 mx-auto mb-3 text-success" />
<p className="text-sm">Todas las obligaciones fiscales estan al dia</p>
</div>
) : (
<div className="space-y-2">
{alertasManuales.map((alerta: any) => {
const esPago = alerta.tipo.startsWith('pago-');
const Icon = prioridadIcons[alerta.prioridad as keyof typeof prioridadIcons] || AlertTriangle;
return (
<div
key={alerta.id}
className={cn(
'p-3 rounded-lg border',
alerta.prioridad === 'alta' && 'border-l-4 border-l-destructive bg-destructive/5',
alerta.prioridad === 'media' && 'border-l-4 border-l-warning bg-warning/5',
)}
>
<div className="flex items-start gap-3">
<Icon className={cn(
'h-5 w-5 mt-0.5 flex-shrink-0',
alerta.prioridad === 'alta' && 'text-destructive',
alerta.prioridad === 'media' && 'text-warning',
)} />
<div className="flex-1 min-w-0">
<h4 className="font-medium text-sm">{alerta.titulo}</h4>
<p className="text-xs text-muted-foreground mt-1">{alerta.mensaje}</p>
</div>
</div>
<div className="mt-2 flex items-center justify-between">
<span className="text-xs text-muted-foreground">
Vencio: {(() => {
const d = new Date(alerta.fechaVencimiento);
return isNaN(d.getTime()) ? '' : d.toLocaleDateString('es-MX', { day: 'numeric', month: 'short', year: 'numeric' });
})()}
</span>
<Button
variant="outline"
size="sm"
onClick={() => handleResolver(alerta.id)}
>
<Check className="h-3 w-3 mr-1" />
{esPago ? 'Marcar como pagado' : 'Marcar como presentada'}
</Button>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
</DashboardShell>
);
}

View File

@@ -0,0 +1,341 @@
'use client';
import { useState, useMemo } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle, Button, SortableHeader, Input } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { formatCurrency } from '@/lib/utils';
import { exportToExcel } from '@/lib/export-excel';
import { useTableSort } from '@horux/shared-ui';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
import { Eye, Download, CheckSquare, Square, EyeOff, Filter, RotateCcw } from 'lucide-react';
import type { Cfdi } from '@horux/shared';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
const TIPO_ALERTA = 'tipo-relacion-sospechosa';
const EXCEL_COLUMNS = [
{ header: 'UUID', key: 'uuid', width: 40 },
{ header: 'Fecha', key: '_fecha', width: 15 },
{ header: 'RFC Emisor', key: 'rfcEmisor', width: 15 },
{ header: 'Nombre Emisor', key: 'nombreEmisor', width: 30 },
{ header: 'RFC Receptor', key: 'rfcReceptor', width: 15 },
{ header: 'Nombre Receptor', key: 'nombreReceptor', width: 30 },
{ header: 'TipoRelacion', key: 'cfdiTipoRelacion', width: 14 },
{ header: 'CFDIs Relacionados', key: 'cfdisRelacionados', width: 50 },
{ header: 'Total MXN', key: '_totalMxn', width: 15 },
];
function prepareRows(data: any[]) {
return data.map((c) => ({
...c,
_fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
_totalMxn: Number(c.totalMxn || 0),
}));
}
export default function TipoRelacionSospechosaPage() {
const [selectedCfdi, setSelectedCfdi] = useState<Cfdi | null>(null);
const [checked, setChecked] = useState<Set<string>>(new Set());
const [view, setView] = useState<'activos' | 'descartados'>('activos');
const { selectedContribuyenteId } = useContribuyenteStore();
const queryClient = useQueryClient();
const [fechaDesde, setFechaDesde] = useState('');
const [fechaHasta, setFechaHasta] = useState('');
const [tipoRelFilter, setTipoRelFilter] = useState<string>('');
const activosQ = useQuery({
queryKey: ['drilldown-tipo-relacion-sospechosa', selectedContribuyenteId],
queryFn: async () => {
const params = new URLSearchParams();
if (selectedContribuyenteId) params.set('contribuyenteId', selectedContribuyenteId);
const res = await apiClient.get<Cfdi[]>(`/alertas/drilldown/${TIPO_ALERTA}?${params}`);
return res.data;
},
enabled: view === 'activos',
});
const descartadosQ = useQuery({
queryKey: ['descartados-tipo-relacion-sospechosa', selectedContribuyenteId],
queryFn: async () => {
const params = new URLSearchParams({ tipoAlerta: TIPO_ALERTA });
if (selectedContribuyenteId) params.set('contribuyenteId', selectedContribuyenteId);
const res = await apiClient.get<{ data: Cfdi[] }>(`/alertas/descartados?${params}`);
return res.data.data;
},
enabled: view === 'descartados',
});
const data = view === 'activos' ? activosQ.data : descartadosQ.data;
const isLoading = view === 'activos' ? activosQ.isLoading : descartadosQ.isLoading;
const tiposRelUnicos = useMemo(() => {
if (!data) return [];
const set = new Set<string>();
data.forEach((c: any) => {
if (c.cfdiTipoRelacion) set.add(c.cfdiTipoRelacion);
});
return [...set].sort();
}, [data]);
const visibleData = useMemo(() => {
if (!data) return [];
let filtered = data;
if (fechaDesde) filtered = filtered.filter(c => c.fechaEmision >= fechaDesde);
if (fechaHasta) filtered = filtered.filter(c => c.fechaEmision <= fechaHasta + 'T23:59:59');
if (tipoRelFilter) filtered = filtered.filter((c: any) => c.cfdiTipoRelacion === tipoRelFilter);
return filtered;
}, [data, fechaDesde, fechaHasta, tipoRelFilter]);
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'total'>(
visibleData,
{
fecha: (c) => new Date(c.fechaEmision).getTime(),
total: (c) => Number(c.totalMxn || 0),
},
'fecha',
);
const handleExport = () => {
if (!sortedData || sortedData.length === 0) return;
exportToExcel(prepareRows(sortedData), EXCEL_COLUMNS, 'cfdis-tipo-relacion-sospechosa');
};
const toggleCheck = (id: string) => {
setChecked(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
const toggleSelectAll = () => {
if (!sortedData) return;
if (checked.size === sortedData.length) {
setChecked(new Set());
} else {
setChecked(new Set(sortedData.map(c => String(c.id))));
}
};
const invalidateAll = () => {
queryClient.invalidateQueries({ queryKey: ['drilldown-tipo-relacion-sospechosa'] });
queryClient.invalidateQueries({ queryKey: ['descartados-tipo-relacion-sospechosa'] });
queryClient.invalidateQueries({ queryKey: ['alertas-automaticas'] });
queryClient.invalidateQueries({ queryKey: ['alertas'] });
};
const handleDescartar = async () => {
const cfdiIds = [...checked].map(id => Number(id));
try {
await apiClient.post('/alertas/descartar', { cfdiIds, tipoAlerta: TIPO_ALERTA });
setChecked(new Set());
invalidateAll();
} catch {
alert('Error al descartar');
}
};
const handleRestaurar = async () => {
const cfdiIds = [...checked].map(id => Number(id));
try {
await apiClient.delete('/alertas/descartar', { data: { cfdiIds, tipoAlerta: TIPO_ALERTA } });
setChecked(new Set());
invalidateAll();
} catch {
alert('Error al restaurar');
}
};
const handleChangeView = (next: 'activos' | 'descartados') => {
setView(next);
setChecked(new Set());
};
const handleClearFilters = () => {
setFechaDesde('');
setFechaHasta('');
setTipoRelFilter('');
};
const hasActiveFilters = fechaDesde || fechaHasta || tipoRelFilter;
const allChecked = sortedData && sortedData.length > 0 && checked.size === sortedData.length;
return (
<DashboardShell title="CFDI con Tipo de Relación sospechoso">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-base">
{view === 'activos'
? 'Notas de crédito (E) que referencian un CFDI tratado como anticipo por otra factura — posible error de emisor (debería ser TipoRelacion 07)'
: 'CFDIs descartados manualmente — ignorados en la alerta'}
</CardTitle>
<div className="flex items-center gap-2">
<div className="flex rounded-md border bg-background p-0.5 text-sm">
<button
type="button"
onClick={() => handleChangeView('activos')}
className={`px-3 py-1 rounded ${view === 'activos' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
Activos
</button>
<button
type="button"
onClick={() => handleChangeView('descartados')}
className={`px-3 py-1 rounded ${view === 'descartados' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
Descartados
</button>
</div>
{checked.size > 0 && view === 'activos' && (
<Button variant="outline" size="sm" onClick={handleDescartar}>
<EyeOff className="h-4 w-4 mr-1" />
Descartar ({checked.size})
</Button>
)}
{checked.size > 0 && view === 'descartados' && (
<Button variant="outline" size="sm" onClick={handleRestaurar}>
<RotateCcw className="h-4 w-4 mr-1" />
Restaurar ({checked.size})
</Button>
)}
{data && data.length > 0 && (
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="h-4 w-4 mr-1" />
Excel
</Button>
)}
</div>
</div>
<div className="flex flex-wrap items-end gap-3 mt-3 pt-3 border-t">
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Filter className="h-4 w-4" />
Filtros:
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">Desde</label>
<Input
type="date"
value={fechaDesde}
onChange={e => setFechaDesde(e.target.value)}
className="h-8 w-[150px] text-sm"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">Hasta</label>
<Input
type="date"
value={fechaHasta}
onChange={e => setFechaHasta(e.target.value)}
className="h-8 w-[150px] text-sm"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">TipoRelacion</label>
<select
value={tipoRelFilter}
onChange={e => setTipoRelFilter(e.target.value)}
className="h-8 rounded-md border border-input bg-background px-2 text-sm"
>
<option value="">Todos</option>
{tiposRelUnicos.map(t => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={handleClearFilters} className="h-8 text-xs">
Limpiar
</Button>
)}
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : !sortedData || sortedData.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{hasActiveFilters
? 'No hay resultados con los filtros seleccionados'
: view === 'activos'
? 'No hay CFDIs sospechosos'
: 'No hay CFDIs descartados'}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-3 w-8">
<button onClick={toggleSelectAll} className="hover:text-foreground transition-colors">
{allChecked ? <CheckSquare className="h-4 w-4 text-primary" /> : <Square className="h-4 w-4" />}
</button>
</th>
<th className="pb-3 font-medium">UUID</th>
<SortableHeader label="Fecha" active={getSortIndicator('fecha')} onClick={() => toggleSort('fecha')} />
<th className="pb-3 font-medium">Emisor</th>
<th className="pb-3 font-medium">Receptor</th>
<th className="pb-3 font-medium">TipoRel</th>
<th className="pb-3 font-medium">Referenciados</th>
<SortableHeader label="Total MXN" align="right" active={getSortIndicator('total')} onClick={() => toggleSort('total')} />
<th className="pb-3 font-medium"></th>
</tr>
</thead>
<tbody>
{sortedData.map((cfdi: any) => {
const refs = (cfdi.cfdisRelacionados || '').split('|').filter(Boolean);
return (
<tr key={cfdi.id} className={`border-b hover:bg-muted/50 ${checked.has(cfdi.id) ? 'bg-primary/5' : ''}`}>
<td className="py-3">
<button onClick={() => toggleCheck(cfdi.id)} className="hover:text-primary transition-colors">
{checked.has(cfdi.id) ? <CheckSquare className="h-4 w-4 text-primary" /> : <Square className="h-4 w-4 text-muted-foreground" />}
</button>
</td>
<td className="py-3 font-mono text-xs">{cfdi.uuid?.substring(0, 8)}</td>
<td className="py-3">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-3 truncate max-w-[180px]">
<div className="font-mono text-xs">{cfdi.rfcEmisor}</div>
<div className="text-xs text-muted-foreground truncate">{cfdi.nombreEmisor}</div>
</td>
<td className="py-3 truncate max-w-[180px]">
<div className="font-mono text-xs">{cfdi.rfcReceptor}</div>
<div className="text-xs text-muted-foreground truncate">{cfdi.nombreReceptor}</div>
</td>
<td className="py-3 font-mono font-bold text-destructive">{cfdi.cfdiTipoRelacion}</td>
<td className="py-3 font-mono text-xs">
{refs.map((u: string) => (
<div key={u}>{u.substring(0, 8)}</div>
))}
</td>
<td className="py-3 text-right font-medium">{formatCurrency(Number(cfdi.totalMxn))}</td>
<td className="py-3">
<Button variant="ghost" size="sm" onClick={() => setSelectedCfdi(cfdi)} title="Ver factura">
<Eye className="h-4 w-4" />
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
<p className="text-xs text-muted-foreground mt-4">
{sortedData.length} CFDI{sortedData.length !== 1 ? 's' : ''} {view === 'activos' ? 'sospechosos' : 'descartados'}
{hasActiveFilters && data && ` (de ${data.length} total)`}
</p>
</div>
)}
</CardContent>
</Card>
<CfdiViewerModal
cfdi={selectedCfdi}
open={!!selectedCfdi}
onClose={() => setSelectedCfdi(null)}
/>
</DashboardShell>
);
}

View File

@@ -0,0 +1,437 @@
'use client';
import { useState } from 'react';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label } from '@horux/shared-ui';
import { useEventos, useCreateEvento, useUpdateEvento, useDeleteEvento } from '@/lib/hooks/use-calendario';
import { useAuthStore } from '@/stores/auth-store';
import {
Calendar, ChevronLeft, ChevronRight, Check, Clock, FileText,
CreditCard, Plus, X, Pencil, Trash2, Lock, Globe, AlertTriangle,
} from 'lucide-react';
import { cn } from '@horux/shared-ui';
import type { EventoFiscal } from '@horux/shared';
const meses = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
const tipoIcons: Record<string, any> = {
declaracion: FileText,
pago: CreditCard,
obligacion: Clock,
informativa: FileText,
custom: Calendar,
'obligacion-pendiente': Clock,
'obligacion-completada': Check,
'obligacion-atrasada': AlertTriangle,
};
const tipoColors: Record<string, string> = {
declaracion: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
pago: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
obligacion: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
informativa: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
custom: 'bg-violet-100 text-violet-800 dark:bg-violet-900 dark:text-violet-200',
'obligacion-pendiente': 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
'obligacion-completada': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
'obligacion-atrasada': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
};
interface RecordatorioForm {
titulo: string;
descripcion: string;
fechaLimite: string;
notas: string;
privado: boolean;
}
const emptyForm: RecordatorioForm = {
titulo: '',
descripcion: '',
fechaLimite: '',
notas: '',
privado: false,
};
export default function CalendarioPage() {
const [año, setAño] = useState(new Date().getFullYear());
const [mes, setMes] = useState(new Date().getMonth() + 1);
const { data: eventos, isLoading } = useEventos(año);
const createEvento = useCreateEvento();
const updateEvento = useUpdateEvento();
const deleteEvento = useDeleteEvento();
const { user } = useAuthStore();
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState<RecordatorioForm>(emptyForm);
const canEdit = ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'].includes(user?.role || '');
const handlePrevMonth = () => {
if (mes === 1) { setMes(12); setAño(año - 1); }
else setMes(mes - 1);
};
const handleNextMonth = () => {
if (mes === 12) { setMes(1); setAño(año + 1); }
else setMes(mes + 1);
};
const handleToggleComplete = (evento: EventoFiscal) => {
if (!evento.id) return;
if (evento.tipo === 'custom') {
updateEvento.mutate({ id: evento.id, data: { completado: !evento.completado } });
}
};
const handleOpenCreate = () => {
setEditingId(null);
const defaultDate = `${año}-${String(mes).padStart(2, '0')}-15`;
setForm({ ...emptyForm, fechaLimite: defaultDate });
setShowForm(true);
};
const handleOpenEdit = (evento: EventoFiscal) => {
if (!evento.id || evento.tipo !== 'custom') return;
setEditingId(evento.id);
setForm({
titulo: evento.titulo,
descripcion: evento.descripcion || '',
fechaLimite: evento.fechaLimite,
notas: evento.notas || '',
privado: (evento as any).privado ?? false,
});
setShowForm(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingId) {
await updateEvento.mutateAsync({
id: editingId,
data: { titulo: form.titulo, descripcion: form.descripcion, fechaLimite: form.fechaLimite, notas: form.notas, privado: form.privado } as any,
});
} else {
await createEvento.mutateAsync({
titulo: form.titulo,
descripcion: form.descripcion,
tipo: 'custom',
fechaLimite: form.fechaLimite,
recurrencia: 'unica',
notas: form.notas,
privado: form.privado,
} as any);
}
setShowForm(false);
setForm(emptyForm);
setEditingId(null);
} catch {
alert('Error al guardar recordatorio');
}
};
const handleDelete = async (id: number) => {
if (!confirm('¿Eliminar este recordatorio?')) return;
try {
await deleteEvento.mutateAsync(id);
} catch {
alert('Error al eliminar');
}
};
const handleCancelForm = () => {
setShowForm(false);
setForm(emptyForm);
setEditingId(null);
};
// Generate calendar days
const firstDay = new Date(año, mes - 1, 1).getDay();
const daysInMonth = new Date(año, mes, 0).getDate();
const days = Array.from({ length: 42 }, (_, i) => {
const day = i - firstDay + 1;
if (day < 1 || day > daysInMonth) return null;
return day;
});
const getEventosForDay = (day: number) => {
return eventos?.filter(e => {
const fecha = new Date(e.fechaLimite + 'T00:00:00');
return fecha.getFullYear() === año && fecha.getMonth() + 1 === mes && fecha.getDate() === day;
}) || [];
};
const eventosDelMes = eventos?.filter(e => {
const f = new Date(e.fechaLimite + 'T00:00:00');
return f.getFullYear() === año && f.getMonth() + 1 === mes;
}) || [];
return (
<DashboardShell title="Calendario Fiscal">
{/* Modal de crear/editar */}
{showForm && (
<Card className="mb-4">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-base">
{editingId ? 'Editar Recordatorio' : 'Nuevo Recordatorio'}
</CardTitle>
<Button variant="ghost" size="icon" onClick={handleCancelForm}>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="titulo">Título</Label>
<Input
id="titulo"
value={form.titulo}
onChange={e => setForm({ ...form, titulo: e.target.value })}
placeholder="Reunión con contador"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="fechaLimite">Fecha</Label>
<Input
id="fechaLimite"
type="date"
value={form.fechaLimite}
onChange={e => setForm({ ...form, fechaLimite: e.target.value })}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="descripcion">Descripción (opcional)</Label>
<Input
id="descripcion"
value={form.descripcion}
onChange={e => setForm({ ...form, descripcion: e.target.value })}
placeholder="Revisión de declaración mensual"
/>
</div>
<div className="space-y-2">
<Label htmlFor="notas">Notas (opcional)</Label>
<Input
id="notas"
value={form.notas}
onChange={e => setForm({ ...form, notas: e.target.value })}
placeholder="Llevar estados de cuenta"
/>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setForm({ ...form, privado: !form.privado })}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-lg border text-sm transition-colors',
form.privado
? 'border-orange-300 bg-orange-50 text-orange-700 dark:border-orange-700 dark:bg-orange-950 dark:text-orange-300'
: 'border-green-300 bg-green-50 text-green-700 dark:border-green-700 dark:bg-green-950 dark:text-green-300'
)}
>
{form.privado ? <Lock className="h-4 w-4" /> : <Globe className="h-4 w-4" />}
{form.privado ? 'Privado' : 'Público'}
</button>
<span className="text-xs text-muted-foreground">
{form.privado ? 'Solo tú puedes verlo' : 'Visible para todo el equipo'}
</span>
</div>
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={handleCancelForm}>
Cancelar
</Button>
<Button type="submit" disabled={createEvento.isPending || updateEvento.isPending}>
{editingId
? (updateEvento.isPending ? 'Guardando...' : 'Guardar')
: (createEvento.isPending ? 'Creando...' : 'Crear Recordatorio')}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
<div className="grid gap-4 lg:grid-cols-3">
{/* Calendar */}
<Card className="lg:col-span-2">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
{meses[mes - 1]} {año}
</CardTitle>
<div className="flex gap-1">
{canEdit && !showForm && (
<Button variant="outline" size="sm" onClick={handleOpenCreate} className="mr-2">
<Plus className="h-4 w-4 mr-1" />
Recordatorio
</Button>
)}
<Button variant="outline" size="icon" onClick={handlePrevMonth}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={handleNextMonth}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
{/* Leyenda de colores por estado de obligación */}
<div className="flex items-center gap-3 flex-wrap text-xs text-muted-foreground mb-3 pb-2 border-b">
<span className="inline-flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded bg-amber-400" />
Pendiente
</span>
<span className="inline-flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded bg-green-500" />
Completada
</span>
<span className="inline-flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded bg-red-500" />
Atrasada
</span>
<span className="inline-flex items-center gap-1.5 ml-2">
<span className="h-2.5 w-2.5 rounded bg-violet-400" />
Recordatorio custom
</span>
</div>
<div className="grid grid-cols-7 gap-1">
{['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'].map(d => (
<div key={d} className="text-center text-sm font-medium text-muted-foreground py-2">
{d}
</div>
))}
{days.map((day, i) => {
const dayEventos = day ? getEventosForDay(day) : [];
const isToday = day === new Date().getDate() && mes === new Date().getMonth() + 1 && año === new Date().getFullYear();
return (
<div
key={i}
className={cn(
'min-h-[80px] p-1 border rounded-md',
day ? 'bg-background' : 'bg-muted/30',
isToday && 'ring-2 ring-primary'
)}
>
{day && (
<>
<div className={cn('text-sm font-medium', isToday && 'text-primary')}>{day}</div>
<div className="space-y-1 mt-1">
{dayEventos.slice(0, 2).map((e, idx) => {
const Icon = tipoIcons[e.tipo] || Calendar;
return (
<div
key={`${e.id}-${idx}`}
className={cn(
'text-xs px-1 py-0.5 rounded truncate flex items-center gap-1',
tipoColors[e.tipo],
e.completado && 'opacity-50 line-through',
e.tipo === 'custom' && canEdit && 'cursor-pointer'
)}
title={e.titulo}
onClick={() => e.tipo === 'custom' && canEdit && handleOpenEdit(e)}
>
<Icon className="h-3 w-3 flex-shrink-0" />
<span className="truncate">{e.titulo}</span>
</div>
);
})}
{dayEventos.length > 2 && (
<div className="text-xs text-muted-foreground">+{dayEventos.length - 2} más</div>
)}
</div>
</>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Event List */}
<Card>
<CardHeader>
<CardTitle>Eventos del Mes</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-4 text-muted-foreground">Cargando...</div>
) : eventosDelMes.length === 0 ? (
<div className="text-center py-4 text-muted-foreground">No hay eventos este mes</div>
) : (
<div className="space-y-3">
{eventosDelMes.map((evento, idx) => {
const Icon = tipoIcons[evento.tipo] || FileText;
const isCustom = evento.tipo === 'custom';
return (
<div
key={`${evento.fechaLimite}-${evento.titulo}-${idx}`}
className={cn(
'p-3 rounded-lg border',
evento.completado && 'opacity-50'
)}
>
<div className="flex items-start gap-2">
<div className={cn('p-1.5 rounded', tipoColors[evento.tipo] || 'bg-muted')}>
<Icon className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1">
<h4 className={cn('font-medium text-sm truncate', evento.completado && 'line-through')}>
{evento.titulo}
</h4>
{isCustom && (evento as any).privado && (
<Lock className="h-3 w-3 text-muted-foreground flex-shrink-0" />
)}
</div>
{evento.descripcion && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">{evento.descripcion}</p>
)}
<p className="text-xs text-muted-foreground mt-1">
{new Date(evento.fechaLimite + 'T00:00:00').toLocaleDateString('es-MX', {
day: 'numeric',
month: 'short',
})}
</p>
</div>
{isCustom && canEdit && (
<div className="flex gap-1 flex-shrink-0">
<Button
variant="ghost" size="icon" className="h-7 w-7"
onClick={() => handleToggleComplete(evento)}
title={evento.completado ? 'Marcar pendiente' : 'Marcar completado'}
>
<Check className={cn('h-3.5 w-3.5', evento.completado && 'text-green-600')} />
</Button>
<Button
variant="ghost" size="icon" className="h-7 w-7"
onClick={() => handleOpenEdit(evento)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost" size="icon" className="h-7 w-7 text-destructive"
onClick={() => evento.id && handleDelete(evento.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)}
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
</DashboardShell>
);
}

View File

@@ -0,0 +1,539 @@
'use client';
import { useState } from 'react';
import {
Button, Card, CardContent, CardHeader, CardTitle, Input, Label,
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
cn,
} from '@horux/shared-ui';
import { useQueryClient } from '@tanstack/react-query';
import {
FolderOpen, Plus, Trash2, ChevronDown, ChevronUp, X,
Users, Building2, FolderPlus, UserCog,
} from 'lucide-react';
import {
useCarteras, useCreateCartera, useDeleteCartera,
useCarteraEntidades, useSubcarteras, useCreateSubcartera,
useSupervisores,
} from '@/lib/hooks/use-carteras';
import {
addEntidadToCartera, removeEntidadFromCartera,
} from '@/lib/api/carteras';
import { useContribuyentes } from '@/lib/hooks/use-contribuyentes';
import { useUsuarios } from '@/lib/hooks/use-usuarios';
import { useAuthStore } from '@/stores/auth-store';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import type { Cartera } from '@/lib/api/carteras';
/* ------------------------------------------------------------------ */
/* SubcarteraCard */
/* ------------------------------------------------------------------ */
function SubcarteraCard({ sub, usuarios, contribuyentes, onDelete }: {
sub: Cartera;
usuarios: any[];
contribuyentes: any[];
onDelete: () => void;
}) {
const [expanded, setExpanded] = useState(false);
const qc = useQueryClient();
const { data: entidadIds, isLoading } = useCarteraEntidades(expanded ? sub.id : null);
const [addingEntidad, setAddingEntidad] = useState(false);
const [selectedEntidadId, setSelectedEntidadId] = useState('');
const [busy, setBusy] = useState(false);
const entidadMap = Object.fromEntries(
(contribuyentes ?? []).map((c: any) => [c.id, { rfc: c.rfc, nombre: c.nombre }])
);
const available = (contribuyentes ?? []).filter(
(c: any) => !(entidadIds ?? []).includes(c.id)
);
const auxiliarUser = usuarios?.find((u: any) => u.id === sub.auxiliarUserId);
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['cartera-entidades', sub.id] });
qc.invalidateQueries({ queryKey: ['subcarteras'] });
qc.invalidateQueries({ queryKey: ['carteras'] });
};
const handleAddEntidad = async () => {
if (!selectedEntidadId) return;
setBusy(true);
try {
await addEntidadToCartera(sub.id, selectedEntidadId);
setSelectedEntidadId('');
setAddingEntidad(false);
invalidate();
} finally { setBusy(false); }
};
const handleRemoveEntidad = async (entidadId: string) => {
setBusy(true);
try {
await removeEntidadFromCartera(sub.id, entidadId);
invalidate();
} finally { setBusy(false); }
};
return (
<div className="border rounded-lg p-3 bg-muted/20">
<div className="flex items-center justify-between">
<button onClick={() => setExpanded(!expanded)} className="flex items-center gap-2 flex-1 text-left">
<UserCog className="h-4 w-4 text-muted-foreground" />
<div>
<span className="font-medium text-sm">{sub.nombre}</span>
{auxiliarUser && (
<span className="text-xs text-muted-foreground ml-2">({auxiliarUser.nombre})</span>
)}
</div>
<span className="text-xs text-muted-foreground ml-auto mr-2">{sub.entidadesCount} RFCs</span>
{expanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
</button>
<button onClick={onDelete} className="text-muted-foreground hover:text-destructive p-1">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
{expanded && (
<div className="mt-3 space-y-2">
{!addingEntidad && (
<Button variant="ghost" size="sm" onClick={() => setAddingEntidad(true)} className="h-7 gap-1 text-xs">
<Plus className="h-3 w-3" /> Asignar RFC
</Button>
)}
{addingEntidad && (
<div className="flex items-center gap-2">
<select className="flex-1 rounded-md border bg-background px-2 py-1 text-sm" value={selectedEntidadId} onChange={e => setSelectedEntidadId(e.target.value)}>
<option value="">-- Seleccionar RFC --</option>
{available.map((c: any) => <option key={c.id} value={c.id}>{c.rfc} {c.nombre}</option>)}
</select>
<Button size="sm" onClick={handleAddEntidad} disabled={!selectedEntidadId || busy}>Agregar</Button>
<Button size="sm" variant="ghost" onClick={() => { setAddingEntidad(false); setSelectedEntidadId(''); }}>Cancelar</Button>
</div>
)}
{isLoading ? (
<p className="text-xs text-muted-foreground">Cargando...</p>
) : !entidadIds || entidadIds.length === 0 ? (
<p className="text-xs text-muted-foreground">Sin RFCs asignados a esta subcartera.</p>
) : (
<ul className="space-y-1">
{entidadIds.map(id => {
const info = entidadMap[id];
return (
<li key={id} className="flex items-center justify-between bg-background rounded px-2 py-1 text-sm">
<span>{info ? <><span className="font-mono text-xs">{info.rfc}</span> <span className="text-muted-foreground">{info.nombre}</span></> : id}</span>
<button onClick={() => handleRemoveEntidad(id)} disabled={busy} className="text-muted-foreground hover:text-destructive"><X className="h-3 w-3" /></button>
</li>
);
})}
</ul>
)}
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* CarteraDetail */
/* ------------------------------------------------------------------ */
function CarteraDetail({ cartera, canEdit = true, canManageSubcarteras = true }: { cartera: Cartera; canEdit?: boolean; canManageSubcarteras?: boolean }) {
const qc = useQueryClient();
const { data: contribuyentes } = useContribuyentes();
const { data: usuarios } = useUsuarios();
const { data: entidadIds, isLoading: loadingEntidades } = useCarteraEntidades(cartera.id);
const { data: subcarteras, isLoading: loadingSubs } = useSubcarteras(cartera.id);
const createSub = useCreateSubcartera();
const [addingEntidad, setAddingEntidad] = useState(false);
const [selectedEntidadId, setSelectedEntidadId] = useState('');
const [showCreateSub, setShowCreateSub] = useState(false);
const [subForm, setSubForm] = useState({ nombre: '', auxiliarUserId: '' });
const [busy, setBusy] = useState(false);
const entidadMap = Object.fromEntries(
(contribuyentes ?? []).map((c) => [c.id, { rfc: c.rfc, nombre: c.nombre }])
);
const available = (contribuyentes ?? []).filter(
(c) => !(entidadIds ?? []).includes(c.id)
);
// Auxiliares available for subcarteras (those assigned to this supervisor)
const auxiliares = (usuarios ?? []).filter((u: any) => u.role === 'auxiliar');
const supervisorUser = usuarios?.find((u: any) => u.id === cartera.supervisorUserId);
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['cartera-entidades', cartera.id] });
qc.invalidateQueries({ queryKey: ['subcarteras', cartera.id] });
qc.invalidateQueries({ queryKey: ['carteras'] });
};
const handleAddEntidad = async () => {
if (!selectedEntidadId) return;
setBusy(true);
try {
await addEntidadToCartera(cartera.id, selectedEntidadId);
setSelectedEntidadId('');
setAddingEntidad(false);
invalidate();
} finally { setBusy(false); }
};
const handleRemoveEntidad = async (entidadId: string) => {
setBusy(true);
try {
await removeEntidadFromCartera(cartera.id, entidadId);
invalidate();
} finally { setBusy(false); }
};
const handleCreateSubcartera = async () => {
if (!subForm.nombre.trim() || !subForm.auxiliarUserId) return;
try {
await createSub.mutateAsync({
carteraId: cartera.id,
nombre: subForm.nombre.trim(),
auxiliarUserId: subForm.auxiliarUserId,
});
setSubForm({ nombre: '', auxiliarUserId: '' });
setShowCreateSub(false);
} catch (err: any) {
alert(err.response?.data?.message || 'Error al crear subcartera');
}
};
const handleDeleteSubcartera = async (subId: string) => {
if (!confirm('¿Eliminar esta subcartera?')) return;
try {
const { deleteCartera } = await import('@/lib/api/carteras');
await deleteCartera(subId);
invalidate();
} catch (err: any) {
alert(err.response?.data?.message || 'Error al eliminar');
}
};
return (
<div className="border-t mt-4 pt-4 space-y-6">
{/* Supervisor info */}
{supervisorUser && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<UserCog className="h-3.5 w-3.5" />
Supervisor: <span className="font-medium text-foreground">{supervisorUser.nombre}</span> ({supervisorUser.email})
</div>
)}
{/* ---- Contribuyentes ---- */}
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold flex items-center gap-1.5">
<Building2 className="h-4 w-4 text-muted-foreground" />
Contribuyentes ({entidadIds?.length || 0})
</h3>
{canEdit && !addingEntidad && (
<Button variant="ghost" size="sm" onClick={() => setAddingEntidad(true)} className="h-7 gap-1 text-xs">
<Plus className="h-3 w-3" /> Agregar
</Button>
)}
</div>
{canEdit && addingEntidad && (
<div className="flex items-center gap-2 mb-3">
<select className="flex-1 rounded-md border bg-background px-3 py-1.5 text-sm" value={selectedEntidadId} onChange={e => setSelectedEntidadId(e.target.value)}>
<option value="">-- Seleccionar RFC --</option>
{available.map(c => <option key={c.id} value={c.id}>{c.rfc} {c.nombre}</option>)}
</select>
<Button size="sm" onClick={handleAddEntidad} disabled={!selectedEntidadId || busy}>Agregar</Button>
<Button size="sm" variant="ghost" onClick={() => { setAddingEntidad(false); setSelectedEntidadId(''); }}>Cancelar</Button>
</div>
)}
{loadingEntidades ? (
<p className="text-xs text-muted-foreground">Cargando...</p>
) : !entidadIds || entidadIds.length === 0 ? (
<p className="text-xs text-muted-foreground">Sin contribuyentes asignados.</p>
) : (
<ul className="space-y-1">
{entidadIds.map(id => {
const info = entidadMap[id];
return (
<li key={id} className="flex items-center justify-between rounded-md bg-muted/40 px-3 py-1.5 text-sm">
<span>{info ? <><span className="font-mono text-xs">{info.rfc}</span> <span className="text-muted-foreground ml-2">{info.nombre}</span></> : <span className="font-mono text-xs">{id}</span>}</span>
{canEdit && <button onClick={() => handleRemoveEntidad(id)} disabled={busy} className="text-muted-foreground hover:text-destructive ml-2"><X className="h-3.5 w-3.5" /></button>}
</li>
);
})}
</ul>
)}
</div>
{/* ---- Subcarteras ---- */}
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold flex items-center gap-1.5">
<Users className="h-4 w-4 text-muted-foreground" />
Subcarteras ({subcarteras?.length || 0})
</h3>
{canManageSubcarteras && !showCreateSub && (
<Button variant="ghost" size="sm" onClick={() => setShowCreateSub(true)} className="h-7 gap-1 text-xs">
<FolderPlus className="h-3 w-3" /> Nueva subcartera
</Button>
)}
</div>
{canManageSubcarteras && showCreateSub && (
<div className="border rounded-lg p-3 mb-3 space-y-3 bg-muted/20">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs">Nombre</Label>
<Input value={subForm.nombre} onChange={e => setSubForm(p => ({ ...p, nombre: e.target.value }))} placeholder="Ej. Cartera de María" className="h-8 text-sm mt-1" />
</div>
<div>
<Label className="text-xs">Auxiliar</Label>
<select className="w-full h-8 rounded-md border bg-background px-2 text-sm mt-1" value={subForm.auxiliarUserId} onChange={e => setSubForm(p => ({ ...p, auxiliarUserId: e.target.value }))}>
<option value="">-- Seleccionar auxiliar --</option>
{auxiliares.map((u: any) => <option key={u.id} value={u.id}>{u.nombre} ({u.email})</option>)}
</select>
</div>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={handleCreateSubcartera} disabled={!subForm.nombre.trim() || !subForm.auxiliarUserId || createSub.isPending}>Crear</Button>
<Button size="sm" variant="ghost" onClick={() => { setShowCreateSub(false); setSubForm({ nombre: '', auxiliarUserId: '' }); }}>Cancelar</Button>
</div>
</div>
)}
{loadingSubs ? (
<p className="text-xs text-muted-foreground">Cargando...</p>
) : !subcarteras || subcarteras.length === 0 ? (
<p className="text-xs text-muted-foreground">Sin subcarteras. Crea una para asignar RFCs a un auxiliar.</p>
) : (
<div className="space-y-2">
{subcarteras.map(sub => (
<SubcarteraCard
key={sub.id}
sub={sub}
usuarios={usuarios ?? []}
contribuyentes={contribuyentes ?? []}
onDelete={() => handleDeleteSubcartera(sub.id)}
/>
))}
</div>
)}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* CarteraCard */
/* ------------------------------------------------------------------ */
function CarteraCard({ cartera, expanded, onToggle, onDelete, usuarios, canEdit, canManageSubcarteras }: {
cartera: Cartera;
expanded: boolean;
onToggle: () => void;
onDelete: () => void;
usuarios: any[];
canEdit: boolean;
canManageSubcarteras: boolean;
}) {
const supervisorUser = usuarios?.find((u: any) => u.id === cartera.supervisorUserId);
return (
<Card className={cn('transition-shadow', expanded && 'ring-1 ring-primary/30 shadow-md')}>
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-4">
<button onClick={onToggle} className="flex-1 text-left flex items-center gap-2 group">
<FolderOpen className={cn('h-5 w-5 flex-shrink-0 transition-colors', expanded ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground')} />
<div>
<CardTitle className="text-base">{cartera.nombre}</CardTitle>
{cartera.descripcion && <p className="text-xs text-muted-foreground mt-0.5">{cartera.descripcion}</p>}
</div>
{expanded ? <ChevronUp className="h-4 w-4 text-muted-foreground ml-auto" /> : <ChevronDown className="h-4 w-4 text-muted-foreground ml-auto" />}
</button>
{canEdit && (
<Button variant="ghost" size="sm" onClick={onDelete} className="text-destructive hover:text-destructive flex-shrink-0 h-8 w-8 p-0">
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
<div className="flex gap-4 mt-1 pl-7">
{supervisorUser && (
<span className="text-xs text-muted-foreground">
<UserCog className="inline h-3 w-3 mr-1" />
{supervisorUser.nombre}
</span>
)}
<span className="text-xs text-muted-foreground">
<Building2 className="inline h-3 w-3 mr-1" />
{cartera.entidadesCount} RFCs
</span>
<span className="text-xs text-muted-foreground">
<Users className="inline h-3 w-3 mr-1" />
{cartera.subcarterasCount} subcarteras
</span>
</div>
</CardHeader>
{expanded && (
<CardContent className="pt-0">
<CarteraDetail cartera={cartera} canEdit={canEdit} canManageSubcarteras={canManageSubcarteras} />
</CardContent>
)}
</Card>
);
}
/* ------------------------------------------------------------------ */
/* Page */
/* ------------------------------------------------------------------ */
export default function CarterasPage() {
const { user } = useAuthStore();
const userRole = user?.role || 'visor';
const canCreate = userRole === 'owner'; // Create top-level carteras
const canEditCartera = userRole === 'owner'; // Edit/delete top-level carteras + add/remove RFCs
const canManageSubcarteras = userRole === 'owner' || userRole === 'supervisor'; // Create subcarteras
const isAuxiliar = userRole === 'auxiliar';
const { data: carteras, isLoading } = useCarteras();
const { data: supervisores } = useSupervisores();
const { data: usuarios } = useUsuarios();
const createMut = useCreateCartera();
const deleteMut = useDeleteCartera();
const [expandedId, setExpandedId] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [form, setForm] = useState({ nombre: '', descripcion: '', supervisorUserId: '' });
const hasSupervisores = supervisores && supervisores.length > 0;
const resetForm = () => {
setForm({ nombre: '', descripcion: '', supervisorUserId: '' });
setShowCreate(false);
};
const handleCreate = async () => {
if (!form.nombre.trim()) return;
try {
const supervisorUserId = form.supervisorUserId && form.supervisorUserId !== '__self__'
? form.supervisorUserId : undefined;
const cartera = await createMut.mutateAsync({
nombre: form.nombre.trim(),
descripcion: form.descripcion.trim() || undefined,
supervisorUserId,
});
resetForm();
setExpandedId(cartera.id);
} catch (err: any) {
alert(err.response?.data?.message || 'Error al crear cartera');
}
};
const handleDelete = async (cartera: Cartera) => {
if (!confirm(`¿Eliminar la cartera "${cartera.nombre}"? Se eliminarán también sus subcarteras.`)) return;
try {
await deleteMut.mutateAsync(cartera.id);
if (expandedId === cartera.id) setExpandedId(null);
} catch (err: any) {
alert(err.response?.data?.message || 'Error al eliminar cartera');
}
};
return (
<DashboardShell title="Carteras">
<div className="max-w-3xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">
{isAuxiliar ? 'Carteras asignadas a ti' : 'Organiza contribuyentes en carteras y asigna subcarteras a cada auxiliar'}
</p>
</div>
{canCreate && (
<Button onClick={() => setShowCreate(true)} className="flex items-center gap-2">
<Plus className="h-4 w-4" /> Nueva cartera
</Button>
)}
</div>
{/* List */}
{isLoading ? (
<p className="text-muted-foreground">Cargando...</p>
) : !carteras || carteras.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<FolderOpen className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold">Sin carteras</h3>
<p className="text-sm text-muted-foreground mt-1 mb-4">
Crea la primera cartera para organizar tus contribuyentes.
</p>
<Button onClick={() => setShowCreate(true)}>Crear primera cartera</Button>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{carteras.map(cartera => (
<CarteraCard
key={cartera.id}
cartera={cartera}
expanded={expandedId === cartera.id}
onToggle={() => setExpandedId(expandedId === cartera.id ? null : cartera.id)}
onDelete={() => handleDelete(cartera)}
usuarios={usuarios ?? []}
canEdit={canEditCartera}
canManageSubcarteras={canManageSubcarteras}
/>
))}
</div>
)}
{/* Create dialog */}
<Dialog open={showCreate} onOpenChange={open => { if (!open) resetForm(); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Nueva cartera</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label>Nombre *</Label>
<Input value={form.nombre} onChange={e => setForm(p => ({ ...p, nombre: e.target.value }))} placeholder="Ej. Clientes CDMX" autoFocus />
</div>
<div>
<Label>Descripcion (opcional)</Label>
<Input value={form.descripcion} onChange={e => setForm(p => ({ ...p, descripcion: e.target.value }))} placeholder="Descripcion breve" />
</div>
{hasSupervisores ? (
<div>
<Label>Asignar a supervisor</Label>
<Select value={form.supervisorUserId} onValueChange={v => setForm(p => ({ ...p, supervisorUserId: v }))}>
<SelectTrigger>
<SelectValue placeholder="Yo mismo (Owner)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__self__">Yo mismo (Owner)</SelectItem>
{supervisores!.map(s => (
<SelectItem key={s.userId} value={s.userId}>{s.nombre} ({s.email})</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1">Si no seleccionas, la cartera se asigna a ti.</p>
</div>
) : (
<p className="text-xs text-muted-foreground border rounded-md p-3 bg-muted/30">
No hay supervisores registrados. La cartera se asignará a ti como owner.
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={resetForm}>Cancelar</Button>
<Button onClick={handleCreate} disabled={!form.nombre.trim() || createMut.isPending}>
{createMut.isPending ? 'Creando...' : 'Crear'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</DashboardShell>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,615 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
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 { 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 type { Tenant } from '@/lib/api/tenants';
import { isGlobalAdminRfc } from '@horux/shared';
import { getClientesStats, getTenantUsuarios, type TenantUsuario } from '@/lib/api/admin-clientes';
const PLAN_LABELS: Record<string, string> = {
starter: 'Starter',
business: 'Business',
business_ia: 'Business + IA',
enterprise: 'Enterprise',
custom: 'Custom',
mi_empresa: 'Mi Empresa',
mi_empresa_plus: 'Mi Empresa +',
business_control: 'Business Control',
business_cloud: 'Enterprise (Despacho)',
};
type PlanType = 'starter' | 'business' | 'business_ia' | 'enterprise' | 'custom';
export default function ClientesPage() {
const { user } = useAuthStore();
const { data: tenants, isLoading } = useTenants();
const createTenant = useCreateTenant();
const updateTenant = useUpdateTenant();
const deleteTenant = useDeleteTenant();
const { setViewingTenant } = useTenantViewStore();
const router = useRouter();
const queryClient = useQueryClient();
// Periodo del KPI: default mes en curso. El admin puede ajustar para ver
// ingresos / no-renovaciones de otros rangos.
const today = new Date();
const defaultFrom = useMemo(() => `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-01`, []);
const defaultTo = useMemo(() => {
const last = new Date(today.getFullYear(), today.getMonth() + 1, 0);
return `${last.getFullYear()}-${String(last.getMonth() + 1).padStart(2, '0')}-${String(last.getDate()).padStart(2, '0')}`;
}, []);
const [from, setFrom] = useState(defaultFrom);
const [to, setTo] = useState(defaultTo);
const isGlobal = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
// `enabled: !!user` en lugar de `isGlobal` — evita timing donde
// platformRoles no haya llegado aún del store. El backend gatea por
// requireStaff así que un user sin permiso recibe 403 y react-query
// maneja el error sin crash. Lo importante es que el query CORRA al
// montar la página.
const { data: stats } = useQuery({
queryKey: ['admin-clientes-stats', from, to],
queryFn: () => getClientesStats(from, to),
enabled: !!user,
});
// Map tenantId → activeUsers para lookup O(1) cuando renderizamos la lista.
const usuariosPorTenant = useMemo(() => {
const m = new Map<string, number>();
(stats?.usuariosPorCliente ?? []).forEach(u => m.set(u.tenantId, u.activeUsers));
return m;
}, [stats]);
const [usuariosTenantId, setUsuariosTenantId] = useState<string | null>(null);
const [usuariosTenantNombre, setUsuariosTenantNombre] = useState<string>('');
const { data: usuariosDetalle, isLoading: isUsuariosLoading } = useQuery({
queryKey: ['admin-clientes-usuarios', usuariosTenantId],
queryFn: () => usuariosTenantId ? getTenantUsuarios(usuariosTenantId) : Promise.resolve([] as TenantUsuario[]),
enabled: !!usuariosTenantId,
});
const [showForm, setShowForm] = useState(false);
const [editingTenant, setEditingTenant] = useState<Tenant | null>(null);
const [formData, setFormData] = useState<{
nombre: string;
rfc: string;
plan: PlanType;
adminEmail: string;
adminNombre: string;
amount: number;
}>({
nombre: '',
rfc: '',
plan: 'starter',
adminEmail: '',
adminNombre: '',
amount: 0,
});
// Only global admin can access this page
if (!isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles)) {
return (
<>
<Header title="Clientes" />
<main className="p-6">
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">
No tienes permisos para ver esta página.
</p>
</CardContent>
</Card>
</main>
</>
);
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingTenant) {
await updateTenant.mutateAsync({ id: editingTenant.id, data: formData });
setEditingTenant(null);
} else {
await createTenant.mutateAsync(formData);
}
setFormData({ nombre: '', rfc: '', plan: 'starter', adminEmail: '', adminNombre: '', amount: 0 });
setShowForm(false);
} catch (error) {
console.error('Error:', error);
}
};
const handleEdit = (tenant: Tenant) => {
setEditingTenant(tenant);
setFormData({
nombre: tenant.nombre,
rfc: tenant.rfc,
plan: tenant.plan as PlanType,
adminEmail: '',
adminNombre: '',
amount: 0,
});
setShowForm(true);
};
const handleDelete = async (tenant: Tenant) => {
if (confirm(`¿Eliminar el cliente "${tenant.nombre}"? Esta acción desactivará el cliente.`)) {
try {
await deleteTenant.mutateAsync(tenant.id);
} catch (error) {
console.error('Error deleting tenant:', error);
}
}
};
const handleCancelForm = () => {
setShowForm(false);
setEditingTenant(null);
setFormData({ nombre: '', rfc: '', plan: 'starter', adminEmail: '', adminNombre: '', amount: 0 });
};
const handleViewClient = (tenantId: string, tenantName: string) => {
setViewingTenant(tenantId, tenantName);
queryClient.invalidateQueries();
router.push('/dashboard');
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('es-MX', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
// Reuse PLAN_LABELS global (declarado al top del archivo) que cubre todos
// los planes — legacy + despacho + custom. El planColors local se mantiene
// chico con un fallback genérico para planes nuevos.
const planColors: Record<string, string> = {
starter: 'bg-muted text-muted-foreground',
business: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100',
business_ia: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100',
enterprise: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100',
mi_empresa: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100',
mi_empresa_plus: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100',
business_control: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-100',
business_cloud: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100',
custom: 'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-100',
};
return (
<>
<Header title="Gestión de Clientes" />
<main className="p-6 space-y-6">
{/* Selector de periodo + acción */}
<Card>
<CardContent className="pt-6 flex flex-col md:flex-row items-start md:items-center gap-4 justify-between">
<div className="flex items-center gap-3">
<Calendar className="h-5 w-5 text-muted-foreground" />
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Periodo:</span>
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="w-[150px]" />
<span className="text-muted-foreground">a</span>
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="w-[150px]" />
</div>
</div>
<Button onClick={() => setShowForm(true)}>
<Plus className="h-4 w-4 mr-2" />
Agregar Cliente
</Button>
</CardContent>
</Card>
{/* KPIs */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{/* Total clientes activos */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="p-3 bg-primary/10 rounded-lg">
<Building className="h-6 w-6 text-primary" />
</div>
<div>
<p className="text-2xl font-bold">{tenants?.length || 0}</p>
<p className="text-sm text-muted-foreground">Clientes registrados</p>
</div>
</div>
</CardContent>
</Card>
{/* Suscripciones activas con breakdown por plan */}
<Card>
<CardContent className="pt-6 space-y-2">
<div className="flex items-center gap-3">
<div className="p-2 bg-emerald-100 dark:bg-emerald-900/30 rounded-lg">
<Users className="h-5 w-5 text-emerald-600" />
</div>
<div>
<p className="text-2xl font-bold">
{(stats?.suscripcionesPorPlan ?? []).reduce((s, p) => s + p.count, 0)}
</p>
<p className="text-sm text-muted-foreground">Suscripciones activas</p>
</div>
</div>
{stats && stats.suscripcionesPorPlan.length > 0 && (
<div className="pt-2 space-y-1 text-xs">
{stats.suscripcionesPorPlan.map(p => (
<div key={p.plan} className="flex justify-between">
<span className="text-muted-foreground">{PLAN_LABELS[p.plan] ?? p.plan}</span>
<span className="font-medium">{p.count}</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Ingresos del periodo */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="p-3 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
<DollarSign className="h-6 w-6 text-amber-600" />
</div>
<div>
<p className="text-2xl font-bold">
${(stats?.ingresos.total ?? 0).toLocaleString('es-MX', { maximumFractionDigits: 0 })}
</p>
<p className="text-sm text-muted-foreground">
Ingresos del periodo · {stats?.ingresos.paymentsCount ?? 0} pagos
</p>
</div>
</div>
</CardContent>
</Card>
{/* No renovaciones */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="p-3 bg-red-100 dark:bg-red-900/30 rounded-lg">
<AlertCircle className="h-6 w-6 text-red-600" />
</div>
<div>
<p className="text-2xl font-bold">{stats?.noRenovaciones.length ?? 0}</p>
<p className="text-sm text-muted-foreground">No renovaron en el periodo</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Detalle de no renovaciones */}
{stats && stats.noRenovaciones.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-red-600" />
Clientes que no renovaron en el periodo
</CardTitle>
<CardDescription>
Suscripciones cuyo periodo terminó dentro del rango y quedaron en estado terminal
(cancelada, prueba expirada o pausada).
</CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4">Cliente</th>
<th className="py-2 pr-4">RFC</th>
<th className="py-2 pr-4">Plan</th>
<th className="py-2 pr-4">Vence</th>
<th className="py-2 pr-4">Estado</th>
</tr>
</thead>
<tbody>
{stats.noRenovaciones.map(nr => (
<tr key={nr.tenantId} className="border-b last:border-0 hover:bg-muted/40">
<td className="py-2 pr-4 font-medium">{nr.tenantNombre}</td>
<td className="py-2 pr-4 font-mono text-xs">{nr.rfc}</td>
<td className="py-2 pr-4">{PLAN_LABELS[nr.plan] ?? nr.plan}</td>
<td className="py-2 pr-4 text-xs">
{nr.currentPeriodEnd ? new Date(nr.currentPeriodEnd).toLocaleDateString('es-MX') : '—'}
</td>
<td className="py-2 pr-4">
<span className="px-2 py-0.5 rounded-full text-xs bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100">
{nr.statusActual}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
{/* Modal de usuarios por cliente */}
{usuariosTenantId && (
<div
className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
onClick={() => setUsuariosTenantId(null)}
>
<div
className="bg-background rounded-lg max-w-2xl w-full max-h-[80vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6 border-b flex items-center justify-between">
<div>
<h3 className="font-semibold">Usuarios de {usuariosTenantNombre}</h3>
<p className="text-sm text-muted-foreground">
{usuariosDetalle?.length ?? 0} usuarios activos
</p>
</div>
<Button variant="ghost" size="icon" onClick={() => setUsuariosTenantId(null)}>
<X className="h-4 w-4" />
</Button>
</div>
<div className="p-6">
{isUsuariosLoading ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
) : (
<div className="space-y-2">
{(usuariosDetalle ?? []).map(u => (
<div key={u.userId} className="flex items-center justify-between p-3 rounded border">
<div>
<p className="font-medium text-sm">{u.nombre}</p>
<p className="text-xs text-muted-foreground">{u.email}</p>
</div>
<div className="flex items-center gap-2">
{u.isOwner && (
<span className="px-2 py-0.5 rounded-full text-xs bg-primary/10 text-primary">Owner</span>
)}
<span className="px-2 py-0.5 rounded-full text-xs bg-muted">{u.rol}</span>
</div>
</div>
))}
{(usuariosDetalle ?? []).length === 0 && (
<p className="text-sm text-muted-foreground text-center py-6">Sin usuarios activos</p>
)}
</div>
)}
</div>
</div>
</div>
)}
{/* Add/Edit Client Form */}
{showForm && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">
{editingTenant ? 'Editar Cliente' : 'Nuevo Cliente'}
</CardTitle>
<CardDescription>
{editingTenant
? 'Modifica los datos del cliente'
: 'Registra un nuevo cliente para gestionar su facturación'}
</CardDescription>
</div>
<Button variant="ghost" size="icon" onClick={handleCancelForm}>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="nombre">Nombre de la Empresa</Label>
<Input
id="nombre"
value={formData.nombre}
onChange={(e) => setFormData({ ...formData, nombre: e.target.value })}
placeholder="Empresa SA de CV"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="rfc">RFC</Label>
<Input
id="rfc"
value={formData.rfc}
onChange={(e) => setFormData({ ...formData, rfc: e.target.value.toUpperCase() })}
placeholder="XAXX010101000"
maxLength={14}
required
disabled={!!editingTenant} // Can't change RFC after creation
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="plan">Plan</Label>
<Select
value={formData.plan}
onValueChange={(value) =>
setFormData({ ...formData, plan: value as PlanType })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="starter">Starter (legacy) Sin CFDIs, 1 usuario</SelectItem>
<SelectItem value="business">Business (legacy) 50 CFDIs, 3 usuarios</SelectItem>
<SelectItem value="business_ia">Business + IA (legacy)</SelectItem>
<SelectItem value="enterprise">Enterprise (legacy) 100 CFDIs, ilimitados</SelectItem>
<SelectItem value="custom">Custom Sin cobro, sin fecha fin (despacho)</SelectItem>
</SelectContent>
</Select>
</div>
{/* Campos de admin y suscripción — 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>
</div>
{formData.plan !== 'custom' && (
<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"
/>
</div>
</div>
)}
{formData.plan === 'custom' && (
<p className="text-xs text-muted-foreground">
Plan Custom no genera cobro ni suscripción. Vigencia indefinida.
</p>
)}
</>
)}
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={handleCancelForm}>
Cancelar
</Button>
<Button type="submit" disabled={createTenant.isPending || updateTenant.isPending}>
{editingTenant
? (updateTenant.isPending ? 'Guardando...' : 'Guardar Cambios')
: (createTenant.isPending ? 'Creando...' : 'Crear Cliente')}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{/* Clients List */}
<Card>
<CardHeader>
<CardTitle className="text-base">Lista de Clientes</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : tenants && tenants.length > 0 ? (
<div className="space-y-3">
{tenants.map((tenant) => (
<div
key={tenant.id}
className="flex items-center justify-between p-4 rounded-lg border hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-lg bg-primary/10 flex items-center justify-center">
<span className="font-bold text-primary">
{tenant.nombre.substring(0, 2).toUpperCase()}
</span>
</div>
<div>
<p className="font-medium">{tenant.nombre}</p>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span>{tenant.rfc}</span>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${planColors[tenant.plan] ?? 'bg-muted text-muted-foreground'}`}>
{PLAN_LABELS[tenant.plan] ?? tenant.plan}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<button
type="button"
className="flex items-center gap-1 text-sm hover:text-primary transition-colors"
onClick={() => { setUsuariosTenantId(tenant.id); setUsuariosTenantNombre(tenant.nombre); }}
title="Ver usuarios"
>
<Users className="h-4 w-4 text-muted-foreground" />
<span>
{usuariosPorTenant.get(tenant.id) ?? tenant._count?.memberships ?? 0} usuarios
</span>
<ChevronRight className="h-3 w-3" />
</button>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Calendar className="h-3 w-3" />
<span>{formatDate(tenant.createdAt)}</span>
</div>
</div>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
onClick={() => handleViewClient(tenant.id, tenant.nombre)}
>
<Eye className="h-4 w-4 mr-1" />
Ver
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(tenant)}
title="Editar"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(tenant)}
title="Eliminar"
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
No hay clientes registrados
</div>
)}
</CardContent>
</Card>
</main>
</>
);
}

View File

@@ -0,0 +1,411 @@
'use client';
import { useState, useEffect } from 'react';
import { useCfdisConConciliacion, useConciliar, useDesconciliar } from '@/lib/hooks/use-conciliacion';
import { useBancos } from '@/lib/hooks/use-bancos';
import { useRegimenesDelPeriodo } from '@/lib/hooks/use-dashboard';
import { PeriodSelector, RegimenSelector } from '@horux/shared-ui';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Input } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import { formatCurrency } from '@/lib/utils';
import { exportToExcel } from '@/lib/export-excel';
import { Eye, Download, X, CheckCircle } from 'lucide-react';
function getMonthRange(year: number, month: number) {
const start = `${year}-${String(month).padStart(2, '0')}-01`;
const lastDay = new Date(year, month, 0).getDate();
const end = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
return { start, end };
}
export default function ConciliacionPage() {
const now = new Date();
const defaultRange = getMonthRange(now.getFullYear(), now.getMonth() + 1);
const [fechaInicio, setFechaInicio] = useState(defaultRange.start);
const [fechaFin, setFechaFin] = useState(defaultRange.end);
const [regimenSeleccionado, setRegimenSeleccionado] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'EMITIDO' | 'RECIBIDO'>('EMITIDO');
const [selected, setSelected] = useState<Set<number>>(new Set());
const [fechaPago, setFechaPago] = useState('');
const [bancoId, setBancoId] = useState<string>('');
const [selectedCfdi, setSelectedCfdi] = useState<any>(null);
const { user } = useAuthStore();
const isVisor = user?.role === 'visor';
// Data
const { data: regimenes, isLoading: regimenesLoading } = useRegimenesDelPeriodo(fechaInicio, fechaFin);
const { data: cfdis, isLoading } = useCfdisConConciliacion({
tipo: activeTab,
fechaInicio,
fechaFin,
...(regimenSeleccionado && { regimen: regimenSeleccionado }),
});
const { data: bancos } = useBancos();
const conciliarMut = useConciliar();
const desconciliarMut = useDesconciliar();
// Split data
const pendientes = cfdis?.filter((c) => c.conciliado !== 'true') || [];
const conciliadas = cfdis?.filter((c) => c.conciliado === 'true') || [];
// Score cards — tipo P usa monto_pago_mxn, otros usan total_mxn
const getMonto = (c: any) => Number(c.montoMxn || c.totalMxn || 0);
const montoConciliado = conciliadas.reduce((s, c) => s + getMonto(c), 0);
const montoPendiente = pendientes.reduce((s, c) => s + getMonto(c), 0);
// Reset selection on tab/filter change
useEffect(() => {
setSelected(new Set());
}, [activeTab, fechaInicio, fechaFin, regimenSeleccionado]);
// Handlers
const toggleSelect = (id: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const toggleSelectAll = () => {
if (selected.size === pendientes.length && pendientes.length > 0) {
setSelected(new Set());
} else {
setSelected(new Set(pendientes.map((c) => c.id)));
}
};
const handleConciliar = async () => {
if (selected.size === 0 || !fechaPago || !bancoId) return;
try {
await conciliarMut.mutateAsync({
cfdiIds: Array.from(selected),
fechaDePago: fechaPago,
idBanco: parseInt(bancoId),
});
setSelected(new Set());
setFechaPago('');
setBancoId('');
} catch (err: any) {
alert(err.response?.data?.message || 'Error al conciliar');
}
};
const handleDesconciliar = async (conciliacionId: number) => {
if (!confirm('¿Desconciliar este CFDI?')) return;
try {
await desconciliarMut.mutateAsync(conciliacionId);
} catch (err: any) {
alert(err.response?.data?.message || 'Error al desconciliar');
}
};
const handleExport = () => {
if (!cfdis?.length) return;
exportToExcel(
cfdis.map((c) => ({
...c,
_fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
_totalMxn: getMonto(c),
_estado: c.conciliado === 'true' ? 'Conciliado' : 'Pendiente',
_fechaPago: c.conciliacion?.fechaDePago || '',
_banco: c.conciliacion
? `${c.conciliacion.banco} ****${c.conciliacion.terminacionCuenta}`
: '',
})),
[
{ header: 'UUID', key: 'uuid', width: 40 },
{ header: 'Fecha Emisión', key: '_fecha', width: 15 },
{ header: 'RFC Emisor', key: 'rfcEmisor', width: 15 },
{ header: 'Nombre Emisor', key: 'nombreEmisor', width: 30 },
{ header: 'RFC Receptor', key: 'rfcReceptor', width: 15 },
{ header: 'Total MXN', key: '_totalMxn', width: 15 },
{ header: 'Estado', key: '_estado', width: 12 },
{ header: 'Fecha Pago', key: '_fechaPago', width: 15 },
{ header: 'Banco', key: '_banco', width: 20 },
],
`conciliacion-${activeTab.toLowerCase()}`,
);
};
return (
<>
<Header title="Conciliación">
<PeriodSelector
fechaInicio={fechaInicio}
fechaFin={fechaFin}
onChange={(i, f) => {
setFechaInicio(i);
setFechaFin(f);
}}
/>
</Header>
<main className="p-6 space-y-6">
{/* Regimen selector + Export button */}
<div className="flex items-center justify-between">
<RegimenSelector
regimenes={regimenes || []}
selected={regimenSeleccionado}
onChange={setRegimenSeleccionado}
isLoading={regimenesLoading}
/>
<Button variant="outline" size="sm" onClick={handleExport} disabled={!cfdis?.length}>
<Download className="h-4 w-4 mr-1" /> Excel
</Button>
</div>
{/* Score cards */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">Monto Conciliado</p>
<p className="text-2xl font-bold text-success">{formatCurrency(montoConciliado)}</p>
<p className="text-xs text-muted-foreground mt-1">{conciliadas.length} CFDIs</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">Monto Pendiente de Conciliar</p>
<p className="text-2xl font-bold text-destructive">{formatCurrency(montoPendiente)}</p>
<p className="text-xs text-muted-foreground mt-1">{pendientes.length} CFDIs</p>
</CardContent>
</Card>
</div>
{/* Tabs */}
<div className="flex gap-2">
{(['EMITIDO', 'RECIBIDO'] as const).map((tab) => (
<Button
key={tab}
variant={activeTab === tab ? 'default' : 'outline'}
onClick={() => {
setActiveTab(tab);
setSelected(new Set());
}}
>
{tab === 'EMITIDO' ? 'Emitidas' : 'Recibidas'}
</Button>
))}
</div>
{isLoading ? (
<div className="text-sm text-muted-foreground">Cargando...</div>
) : (
<>
{/* Por conciliar */}
<Card>
<CardContent className="pt-6">
<h3 className="font-medium mb-4">Por conciliar ({pendientes.length})</h3>
{pendientes.length === 0 ? (
<p className="text-sm text-muted-foreground">
No hay CFDIs pendientes de conciliar
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
{!isVisor && (
<th className="pb-3 w-8">
<input
type="checkbox"
checked={
selected.size === pendientes.length && pendientes.length > 0
}
onChange={toggleSelectAll}
/>
</th>
)}
<th className="pb-3 font-medium">UUID</th>
<th className="pb-3 font-medium">Fecha</th>
<th className="pb-3 font-medium">RFC Emisor</th>
<th className="pb-3 font-medium">Nombre Emisor</th>
<th className="pb-3 font-medium">RFC Receptor</th>
<th className="pb-3 font-medium">Nombre Receptor</th>
<th className="pb-3 font-medium text-right">Total MXN</th>
<th className="pb-3 font-medium">M. Pago</th>
<th className="pb-3"></th>
</tr>
</thead>
<tbody>
{pendientes.map((cfdi) => (
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
{!isVisor && (
<td className="py-2">
<input
type="checkbox"
checked={selected.has(cfdi.id)}
onChange={() => toggleSelect(cfdi.id)}
/>
</td>
)}
<td className="py-2 font-mono text-xs" title={cfdi.uuid}>
{cfdi.uuid?.substring(0, 8)}
</td>
<td className="py-2 text-xs">
{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}
</td>
<td className="py-2 font-mono text-xs">{cfdi.rfcEmisor}</td>
<td className="py-2 text-xs truncate max-w-[120px]">
{cfdi.nombreEmisor}
</td>
<td className="py-2 font-mono text-xs">{cfdi.rfcReceptor}</td>
<td className="py-2 text-xs truncate max-w-[120px]">
{cfdi.nombreReceptor}
</td>
<td className="py-2 text-right text-xs font-medium">
{formatCurrency(getMonto(cfdi))}
</td>
<td className="py-2 text-xs">{cfdi.metodoPago || '-'}</td>
<td className="py-2">
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedCfdi(cfdi)}
title="Ver factura"
>
<Eye className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* Action bar - only when items selected */}
{!isVisor && selected.size > 0 && (
<div className="sticky bottom-4 z-10 bg-card border rounded-lg shadow-lg p-4 flex items-center gap-4">
<span className="text-sm font-medium">{selected.size} seleccionados</span>
<Select value={bancoId} onValueChange={setBancoId}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Seleccionar banco" />
</SelectTrigger>
<SelectContent>
{bancos?.map((b) => (
<SelectItem key={b.id} value={String(b.id)}>
{b.banco} ****{b.terminacionCuenta}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="date"
value={fechaPago}
onChange={(e) => setFechaPago(e.target.value)}
className="w-44"
/>
<Button
onClick={handleConciliar}
disabled={!fechaPago || !bancoId || conciliarMut.isPending}
>
<CheckCircle className="h-4 w-4 mr-1" />
Conciliar {selected.size} facturas
</Button>
<Button variant="ghost" size="sm" onClick={() => setSelected(new Set())}>
<X className="h-4 w-4" />
</Button>
</div>
)}
{/* Conciliadas */}
<Card>
<CardContent className="pt-6">
<h3 className="font-medium mb-4">Conciliadas ({conciliadas.length})</h3>
{conciliadas.length === 0 ? (
<p className="text-sm text-muted-foreground">No hay CFDIs conciliados</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-3 font-medium">UUID</th>
<th className="pb-3 font-medium">Fecha Emisión</th>
<th className="pb-3 font-medium">RFC Emisor</th>
<th className="pb-3 font-medium">Nombre Emisor</th>
<th className="pb-3 font-medium text-right">Total MXN</th>
<th className="pb-3 font-medium">Fecha Pago</th>
<th className="pb-3 font-medium">Banco</th>
<th className="pb-3"></th>
</tr>
</thead>
<tbody>
{conciliadas.map((cfdi) => (
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
<td className="py-2 font-mono text-xs" title={cfdi.uuid}>
{cfdi.uuid?.substring(0, 8)}
</td>
<td className="py-2 text-xs">
{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}
</td>
<td className="py-2 font-mono text-xs">{cfdi.rfcEmisor}</td>
<td className="py-2 text-xs truncate max-w-[120px]">
{cfdi.nombreEmisor}
</td>
<td className="py-2 text-right text-xs font-medium">
{formatCurrency(getMonto(cfdi))}
</td>
<td className="py-2 text-xs">
{cfdi.conciliacion?.fechaDePago
? new Date(
cfdi.conciliacion.fechaDePago + 'T12:00:00',
).toLocaleDateString('es-MX')
: '-'}
</td>
<td className="py-2 text-xs">
{cfdi.conciliacion
? `${cfdi.conciliacion.banco} ****${cfdi.conciliacion.terminacionCuenta}`
: '-'}
</td>
<td className="py-2 flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedCfdi(cfdi)}
title="Ver factura"
>
<Eye className="h-4 w-4" />
</Button>
{!isVisor && cfdi.conciliacion && (
<Button
variant="ghost"
size="sm"
onClick={() => handleDesconciliar(cfdi.conciliacion!.id)}
title="Desconciliar"
className="text-destructive hover:text-destructive"
>
<X className="h-4 w-4" />
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</>
)}
</main>
<CfdiViewerModal
cfdi={selectedCfdi}
open={!!selectedCfdi}
onClose={() => setSelectedCfdi(null)}
/>
</>
);
}

View File

@@ -0,0 +1,258 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, Button, Input } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import { isGlobalAdminRfc } from '@horux/shared';
import { apiClient } from '@/lib/api/client';
import { Package, ShieldAlert, Pencil, Loader2, Check as CheckIcon, X as XIcon, AlertTriangle, Power, PowerOff } from 'lucide-react';
interface AddonItem {
id: string;
codename: string;
nombre: string;
verticalProfile: string | null;
precio: number;
frecuencia: string;
active: boolean;
delta: unknown;
createdAt: string;
suscripcionesActivas: number;
}
async function listAddons(): Promise<AddonItem[]> {
const res = await apiClient.get<{ data: AddonItem[] }>('/admin/addons/catalogo');
return res.data.data;
}
async function updateAddon(id: string, data: { nombre?: string; precio?: number; active?: boolean }): Promise<void> {
await apiClient.put(`/admin/addons/catalogo/${id}`, data);
}
export default function AddonsPage() {
const { user } = useAuthStore();
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
const queryClient = useQueryClient();
const { data: addons = [], isLoading } = useQuery({
queryKey: ['admin-addons-catalogo'],
queryFn: listAddons,
enabled: isGlobalAdmin,
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: { nombre?: string; precio?: number; active?: boolean } }) => updateAddon(id, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin-addons-catalogo'] }),
});
const [editing, setEditing] = useState<{ id: string; nombre: string; precio: string } | null>(null);
if (!isGlobalAdmin) {
return (
<>
<Header title="Add-ons del catálogo" />
<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 admin global puede modificar el catálogo de add-ons.</p>
</CardContent>
</Card>
</main>
</>
);
}
const startEdit = (item: AddonItem) => {
setEditing({ id: item.id, nombre: item.nombre, precio: String(item.precio) });
};
const cancelEdit = () => setEditing(null);
const saveEdit = async () => {
if (!editing) return;
const precio = Number(editing.precio);
if (!Number.isFinite(precio) || precio < 0) {
alert('El precio debe ser un número no negativo');
return;
}
if (!editing.nombre.trim()) {
alert('El nombre no puede estar vacío');
return;
}
try {
await updateMutation.mutateAsync({
id: editing.id,
data: { nombre: editing.nombre.trim(), precio },
});
setEditing(null);
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al guardar');
}
};
const toggleActive = async (item: AddonItem) => {
if (item.active && item.suscripcionesActivas > 0) {
const confirmar = confirm(
`Hay ${item.suscripcionesActivas} suscripción(es) activa(s) usando este add-on. ` +
`Desactivarlo evitará nuevas contrataciones, pero las existentes siguen vigentes hasta su cancelación. ¿Continuar?`,
);
if (!confirmar) return;
}
try {
await updateMutation.mutateAsync({ id: item.id, data: { active: !item.active } });
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al cambiar estado');
}
};
return (
<>
<Header title="Add-ons del catálogo" />
<main className="p-6 space-y-6">
<Card className="bg-muted/30 border-dashed">
<CardContent className="py-4 text-sm flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 mt-0.5 flex-shrink-0" />
<div>
<p>
Los cambios de precio aplican <strong>a contrataciones nuevas</strong>. Las
suscripciones de add-on vigentes conservan el precio al que se cobraron.
</p>
<p className="text-muted-foreground mt-1">
Desactivar un add-on lo oculta del catálogo público, pero las suscripciones
activas siguen funcionando hasta su cancelación.
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Package className="h-5 w-5" />
Catálogo de add-ons
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-sm text-muted-foreground py-4">Cargando catálogo...</div>
) : addons.length === 0 ? (
<div className="text-sm text-muted-foreground py-4">Sin add-ons configurados.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4">Codename</th>
<th className="py-2 pr-4">Nombre</th>
<th className="py-2 pr-4 text-right">Precio (MXN)</th>
<th className="py-2 pr-4">Frecuencia</th>
<th className="py-2 pr-4 text-right">Suscripciones activas</th>
<th className="py-2 pr-4">Estado</th>
<th className="py-2 pr-4"></th>
</tr>
</thead>
<tbody>
{addons.map(item => {
const isEditing = editing?.id === item.id;
return (
<tr key={item.id} className="border-b last:border-0 hover:bg-muted/40">
<td className="py-3 pr-4 font-mono text-xs text-muted-foreground">{item.codename}</td>
<td className="py-3 pr-4">
{isEditing ? (
<Input
value={editing.nombre}
onChange={(e) => setEditing({ ...editing, nombre: e.target.value })}
className="h-8"
autoFocus
/>
) : (
<span className="font-medium">{item.nombre}</span>
)}
</td>
<td className="py-3 pr-4 text-right">
{isEditing ? (
<Input
type="number"
step="1"
min="0"
value={editing.precio}
onChange={(e) => setEditing({ ...editing, precio: e.target.value })}
className="h-8 w-32 text-right"
/>
) : (
<span className="font-medium">${item.precio.toLocaleString('es-MX')}</span>
)}
</td>
<td className="py-3 pr-4 text-muted-foreground">{item.frecuencia}</td>
<td className="py-3 pr-4 text-right">
<span className={item.suscripcionesActivas > 0 ? 'font-medium' : 'text-muted-foreground'}>
{item.suscripcionesActivas}
</span>
</td>
<td className="py-3 pr-4">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
item.active
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100'
: 'bg-muted text-muted-foreground'
}`}>
{item.active ? 'Activo' : 'Inactivo'}
</span>
</td>
<td className="py-3 pr-4">
<div className="flex items-center justify-end gap-1">
{isEditing ? (
<>
<Button
size="icon"
variant="ghost"
onClick={saveEdit}
disabled={updateMutation.isPending}
title="Guardar"
>
{updateMutation.isPending
? <Loader2 className="h-4 w-4 animate-spin" />
: <CheckIcon className="h-4 w-4 text-green-600" />}
</Button>
<Button size="icon" variant="ghost" onClick={cancelEdit} title="Cancelar">
<XIcon className="h-4 w-4" />
</Button>
</>
) : (
<>
<Button
size="icon"
variant="ghost"
onClick={() => startEdit(item)}
title="Editar nombre y precio"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
onClick={() => toggleActive(item)}
disabled={updateMutation.isPending}
title={item.active ? 'Desactivar' : 'Activar'}
>
{item.active
? <PowerOff className="h-3.5 w-3.5 text-red-600" />
: <Power className="h-3.5 w-3.5 text-green-600" />}
</Button>
</>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</main>
</>
);
}

View File

@@ -0,0 +1,374 @@
'use client';
import { useState } from 'react';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Button, Input, Label } from '@horux/shared-ui';
import { useTimbres } from '@/lib/hooks/use-facturacion';
import { apiClient } from '@/lib/api/client';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { Shield, Upload, Check, AlertCircle, Receipt, Palette, Image } from 'lucide-react';
function CustomizationSection() {
const queryClient = useQueryClient();
const [logoUploading, setLogoUploading] = useState(false);
const [colorSaving, setColorSaving] = useState(false);
const [color, setColor] = useState('#75A4FF');
const [msg, setMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const { data: customization } = useQuery({
queryKey: ['facturapi-customization'],
queryFn: () => apiClient.get('/facturacion/customization').then(r => r.data),
});
useState(() => {
if (customization?.color) setColor(`#${customization.color}`);
});
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validar tipo y tamaño
if (!file.type.startsWith('image/')) {
setMsg({ type: 'error', text: 'Solo se permiten imágenes (PNG, JPG)' });
return;
}
if (file.size > 2 * 1024 * 1024) {
setMsg({ type: 'error', text: 'El logo no debe superar 2MB' });
return;
}
setLogoUploading(true);
setMsg(null);
const reader = new FileReader();
reader.onload = async () => {
const base64 = (reader.result as string).split(',')[1];
try {
await apiClient.post('/facturacion/logo', { logo: base64 });
queryClient.invalidateQueries({ queryKey: ['facturapi-customization'] });
setMsg({ type: 'success', text: 'Logo subido correctamente' });
} catch {
setMsg({ type: 'error', text: 'Error al subir logo' });
} finally {
setLogoUploading(false);
}
};
reader.readAsDataURL(file);
};
const handleColorSave = async () => {
setColorSaving(true);
setMsg(null);
try {
await apiClient.put('/facturacion/color', { color: color.replace('#', '') });
queryClient.invalidateQueries({ queryKey: ['facturapi-customization'] });
setMsg({ type: 'success', text: 'Color actualizado' });
} catch {
setMsg({ type: 'error', text: 'Error al actualizar color' });
} finally {
setColorSaving(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Palette className="h-4 w-4" />
Personalización de Factura
</CardTitle>
<CardDescription>
Logo y color que aparecerán en los PDFs de tus facturas
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Logo */}
<div className="space-y-3">
<Label className="flex items-center gap-2">
<Image className="h-4 w-4" />
Logo de la empresa
</Label>
<div className="flex items-center gap-4">
{customization?.logoUrl && (
<img
src={customization.logoUrl}
alt="Logo"
className="h-16 w-16 object-contain rounded-lg border"
/>
)}
<div className="flex-1">
<Input
type="file"
accept="image/png,image/jpeg,image/jpg"
onChange={handleLogoUpload}
disabled={logoUploading}
/>
<p className="text-xs text-muted-foreground mt-1">
PNG o JPG, máximo 2MB. Recomendado: fondo transparente, 400x400px
</p>
</div>
</div>
</div>
{/* Color */}
<div className="space-y-3">
<Label>Color principal</Label>
<div className="flex items-center gap-3">
<input
type="color"
value={color}
onChange={e => setColor(e.target.value)}
className="h-10 w-14 rounded cursor-pointer border"
/>
<Input
value={color}
onChange={e => setColor(e.target.value)}
placeholder="#75A4FF"
className="w-32 font-mono"
maxLength={7}
/>
<div className="h-10 flex-1 rounded-lg border" style={{ backgroundColor: color }} />
<Button onClick={handleColorSave} disabled={colorSaving} size="sm">
{colorSaving ? 'Guardando...' : 'Guardar color'}
</Button>
</div>
</div>
{/* Mensaje */}
{msg && (
<div className={`p-2 rounded text-sm ${msg.type === 'success' ? 'bg-green-50 text-green-800 dark:bg-green-950 dark:text-green-200' : 'bg-red-50 text-red-800 dark:bg-red-950 dark:text-red-200'}`}>
{msg.text}
</div>
)}
</CardContent>
</Card>
);
}
export default function CsdConfigPage() {
const { selectedContribuyenteId } = useContribuyenteStore();
const { data: orgStatus, isLoading } = useQuery({
queryKey: ['facturapi-org-contrib', selectedContribuyenteId],
queryFn: () => selectedContribuyenteId
? apiClient.get(`/contribuyentes/${selectedContribuyenteId}/facturapi/status`).then(r => r.data)
: apiClient.get('/facturacion/org/status').then(r => r.data),
});
const { data: timbres } = useTimbres();
const queryClient = useQueryClient();
const [uploading, setUploading] = useState(false);
const [cerFile, setCerFile] = useState<string>('');
const [keyFile, setKeyFile] = useState<string>('');
const [password, setPassword] = useState('');
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const handleFileChange = (setter: (v: string) => void) => (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const base64 = (reader.result as string).split(',')[1];
setter(base64);
};
reader.readAsDataURL(file);
};
const handleCreateOrg = async () => {
try {
if (selectedContribuyenteId) {
await apiClient.post(`/contribuyentes/${selectedContribuyenteId}/facturapi/org`);
} else {
await apiClient.post('/facturacion/org');
}
queryClient.invalidateQueries({ queryKey: ['facturapi-org-contrib'] });
setMessage({ type: 'success', text: 'Organización creada en Facturapi' });
} catch (err: any) {
setMessage({ type: 'error', text: err.response?.data?.message || 'Error al crear organización' });
}
};
const handleUploadCsd = async (e: React.FormEvent) => {
e.preventDefault();
if (!cerFile || !keyFile || !password) {
setMessage({ type: 'error', text: 'Todos los campos son requeridos' });
return;
}
setUploading(true);
setMessage(null);
try {
if (selectedContribuyenteId) {
await apiClient.post(`/contribuyentes/${selectedContribuyenteId}/facturapi/csd`, { cerFile, keyFile, password });
} else {
await apiClient.post('/facturacion/csd', { cerFile, keyFile, password });
}
queryClient.invalidateQueries({ queryKey: ['facturapi-org-contrib'] });
setMessage({ type: 'success', text: 'CSD subido correctamente. Ya puedes emitir facturas.' });
setCerFile('');
setKeyFile('');
setPassword('');
} catch (err: any) {
setMessage({ type: 'error', text: err.response?.data?.message || 'Error al subir CSD' });
} finally {
setUploading(false);
}
};
if (isLoading) {
return (
<>
<Header title="Configuración CSD" />
<main className="p-6"><p>Cargando...</p></main>
</>
);
}
return (
<>
<Header title="Configuración CSD" />
<main className="p-6 space-y-6">
{/* Estado de la organización */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Shield className="h-4 w-4" />
Organización Facturapi
</CardTitle>
<CardDescription>
Necesaria para emitir facturas electrónicas (CFDI)
</CardDescription>
</CardHeader>
<CardContent>
{!orgStatus?.configured ? (
<div className="text-center py-4 space-y-3">
<p className="text-sm text-muted-foreground">
No hay organización configurada para este tenant.
</p>
<Button onClick={handleCreateOrg}>Crear Organización</Button>
</div>
) : (
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">ID Organización</span>
<span className="font-mono text-xs">{orgStatus.orgId}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">CSD</span>
<span className={orgStatus.hasCsd ? 'text-green-600' : 'text-orange-600'}>
{orgStatus.hasCsd ? '✓ Configurado' : '✗ Pendiente'}
</span>
</div>
</div>
)}
</CardContent>
</Card>
{/* Subir CSD */}
{orgStatus?.configured && !orgStatus.hasCsd && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Upload className="h-4 w-4" />
Subir Certificado de Sello Digital (CSD)
</CardTitle>
<CardDescription>
El CSD es diferente a la FIEL. Se usa exclusivamente para timbrar facturas.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleUploadCsd} className="space-y-4">
<div className="space-y-2">
<Label>Certificado (.cer)</Label>
<Input type="file" accept=".cer" onChange={handleFileChange(setCerFile)} required />
</div>
<div className="space-y-2">
<Label>Llave privada (.key)</Label>
<Input type="file" accept=".key" onChange={handleFileChange(setKeyFile)} required />
</div>
<div className="space-y-2">
<Label>Contraseña de la llave</Label>
<Input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
</div>
<Button type="submit" disabled={uploading} className="w-full">
{uploading ? 'Subiendo...' : 'Subir CSD'}
</Button>
</form>
</CardContent>
</Card>
)}
{/* CSD ya configurado */}
{orgStatus?.configured && orgStatus.hasCsd && (
<Card>
<CardContent className="pt-6 text-center space-y-2">
<div className="h-12 w-12 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center mx-auto">
<Check className="h-6 w-6 text-green-600" />
</div>
<p className="font-medium">CSD Configurado</p>
<p className="text-sm text-muted-foreground">
Tu Certificado de Sello Digital está activo. Puedes emitir facturas.
</p>
</CardContent>
</Card>
)}
{/* Personalización de factura */}
{orgStatus?.configured && <CustomizationSection />}
{/* Timbres */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Receipt className="h-4 w-4" />
Timbres
</CardTitle>
</CardHeader>
<CardContent>
{timbres?.configured ? (
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Plan</span>
<span className="capitalize">{timbres.tipo}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Usados</span>
<span>{timbres.usados} / {timbres.limite}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Disponibles</span>
<span className="font-bold">{timbres.disponibles}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Vence</span>
<span>{timbres.periodoFin}</span>
</div>
<div className="w-full bg-muted rounded-full h-2 mt-2">
<div
className="bg-primary rounded-full h-2"
style={{ width: `${Math.min(100, ((timbres.usados ?? 0) / (timbres.limite ?? 1)) * 100)}%` }}
/>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-4">
No hay suscripción de timbres configurada. Contacta al dueño de la cuenta.
</p>
)}
</CardContent>
</Card>
{/* Mensajes */}
{message && (
<div className={`p-3 rounded-lg text-sm flex items-center gap-2 ${
message.type === 'success' ? 'bg-green-50 text-green-800 dark:bg-green-950 dark:text-green-200'
: 'bg-red-50 text-red-800 dark:bg-red-950 dark:text-red-200'
}`}>
{message.type === 'success' ? <Check className="h-4 w-4" /> : <AlertCircle className="h-4 w-4" />}
{message.text}
</div>
)}
</main>
</>
);
}

View File

@@ -0,0 +1,180 @@
'use client';
import { useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { Bell, Loader2 } from 'lucide-react';
const EMAIL_LABELS: Record<string, { label: string; description: string; status: 'active' | 'pending' }> = {
documento_subido: {
label: 'Documento subido',
description: 'Notificación cuando se sube una declaración o documento extra del contribuyente.',
status: 'active',
},
weekly_update: {
label: 'Reporte semanal',
description: 'Resumen de KPIs, alertas y discrepancias enviado los lunes 8:00 AM.',
status: 'pending',
},
subscription_expiring: {
label: 'Vencimiento de suscripción',
description: 'Aviso cuando la suscripción del despacho está por vencer.',
status: 'pending',
},
recordatorio_fiscal: {
label: 'Recordatorios fiscales',
description: 'Avisos de obligaciones próximas a vencer (declaraciones, pagos provisionales).',
status: 'pending',
},
};
interface ContribuyentePrefs {
contribuyenteId: string;
rfc: string;
nombre: string;
preferences: Record<string, boolean>;
}
interface ListResponse {
emailTypes: string[];
data: ContribuyentePrefs[];
}
export default function NotificacionesPage() {
const queryClient = useQueryClient();
const { selectedContribuyenteId } = useContribuyenteStore();
const { data, isLoading } = useQuery<ListResponse>({
queryKey: ['notification-preferences'],
queryFn: async () => {
const res = await apiClient.get<ListResponse>('/notificaciones');
return res.data;
},
});
// Aplica el filtro del selector global de contribuyente. Si hay uno
// seleccionado, solo se muestra esa fila. "Todos" muestra todos.
const visibles = useMemo(() => {
if (!data) return [];
if (!selectedContribuyenteId) return data.data;
return data.data.filter(c => c.contribuyenteId === selectedContribuyenteId);
}, [data, selectedContribuyenteId]);
const mutation = useMutation({
mutationFn: async ({ contribuyenteId, emailType, enabled }: { contribuyenteId: string; emailType: string; enabled: boolean }) => {
await apiClient.put('/notificaciones', {
contribuyenteId,
preferences: { [emailType]: enabled },
});
},
onMutate: async ({ contribuyenteId, emailType, enabled }) => {
await queryClient.cancelQueries({ queryKey: ['notification-preferences'] });
const previous = queryClient.getQueryData<ListResponse>(['notification-preferences']);
if (previous) {
queryClient.setQueryData<ListResponse>(['notification-preferences'], {
...previous,
data: previous.data.map(c =>
c.contribuyenteId === contribuyenteId
? { ...c, preferences: { ...c.preferences, [emailType]: enabled } }
: c,
),
});
}
return { previous };
},
onError: (_err, _vars, context) => {
if (context?.previous) queryClient.setQueryData(['notification-preferences'], context.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['notification-preferences'] });
},
});
return (
<>
<Header title="Notificaciones" />
<main className="p-6 space-y-6 max-w-5xl mx-auto">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Bell className="h-4 w-4" />
Correos informativos por contribuyente
</CardTitle>
<CardDescription>
Por default todos los correos están activados. Desactiva los que no quieras recibir para cada cliente. Los correos críticos (welcome, recuperación de contraseña, confirmación de pago) siempre se envían independientemente de esta configuración.
</CardDescription>
</CardHeader>
</Card>
{isLoading ? (
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
Cargando...
</div>
) : visibles.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
{selectedContribuyenteId
? 'El contribuyente seleccionado no tiene preferencias configuradas todavía.'
: 'No hay contribuyentes en este despacho.'}
</CardContent>
</Card>
) : (
visibles.map(contrib => (
<Card key={contrib.contribuyenteId}>
<CardHeader>
<CardTitle className="text-sm font-medium">
{contrib.nombre}
</CardTitle>
<CardDescription className="font-mono text-xs">{contrib.rfc}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{(data?.emailTypes ?? []).map(type => {
const meta = EMAIL_LABELS[type];
if (!meta) return null;
const checked = contrib.preferences[type] !== false;
const isPending = meta.status === 'pending';
return (
<div key={type} className="flex items-start justify-between gap-4 py-2 border-b last:border-0">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{meta.label}</span>
{isPending && (
<span className="text-[10px] uppercase tracking-wide bg-muted text-muted-foreground rounded px-1.5 py-0.5">
Próximamente
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5">{meta.description}</p>
</div>
<label className="inline-flex items-center cursor-pointer flex-shrink-0">
<input
type="checkbox"
className="sr-only peer"
checked={checked}
onChange={e =>
mutation.mutate({
contribuyenteId: contrib.contribuyenteId,
emailType: type,
enabled: e.target.checked,
})
}
/>
<div className="relative w-10 h-6 bg-muted peer-checked:bg-primary rounded-full peer-focus:ring-2 peer-focus:ring-primary/30 transition-colors after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-transform peer-checked:after:translate-x-4" />
</label>
</div>
);
})}
</div>
</CardContent>
</Card>
))
)}
</main>
</>
);
}

View File

@@ -0,0 +1,499 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Input,
Label,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
cn,
} from '@horux/shared-ui';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { apiClient } from '@/lib/api/client';
import { Header } from '@/components/layouts/header';
import { TareasTab } from '@/components/obligaciones/tareas-tab';
import { Plus, Trash2, RotateCcw, Sparkles, ChevronDown, Building2 } from 'lucide-react';
interface Obligacion {
id: string;
catalogoId: string | null;
nombre: string;
fundamento: string | null;
frecuencia: string | null;
fechaLimite: string | null;
categoria: string | null;
activa: boolean;
esRecomendada: boolean;
esCustom: boolean;
}
interface CatalogoItem {
id: string;
nombre: string;
fundamento: string;
frecuencia: string;
fechaLimite: string;
categoria: string;
aplica: string;
}
export default function ObligacionesPage() {
const queryClient = useQueryClient();
const { selectedContribuyenteId, selectedContribuyenteRfc, selectedContribuyenteNombre } =
useContribuyenteStore();
const [obligaciones, setObligaciones] = useState<Obligacion[]>([]);
const [catalogo, setCatalogo] = useState<CatalogoItem[]>([]);
const [loading, setLoading] = useState(false);
const [showAdd, setShowAdd] = useState(false);
const [showRemoved, setShowRemoved] = useState(false);
const [addMode, setAddMode] = useState<'catalogo' | 'custom'>('catalogo');
const [customForm, setCustomForm] = useState({
nombre: '',
fundamento: '',
frecuencia: '',
fechaLimite: '',
categoria: '',
});
const [selectedCatalogoId, setSelectedCatalogoId] = useState('');
const [activeTab, setActiveTab] = useState<'obligaciones' | 'tareas'>('obligaciones');
const fetchObligaciones = useCallback(async () => {
if (!selectedContribuyenteId) return;
setLoading(true);
try {
const { data } = await apiClient.get(
`/contribuyentes/${selectedContribuyenteId}/obligaciones`
);
setObligaciones(data.data);
} catch {
setObligaciones([]);
} finally {
setLoading(false);
}
}, [selectedContribuyenteId]);
useEffect(() => {
fetchObligaciones();
}, [fetchObligaciones]);
useEffect(() => {
apiClient
.get('/contribuyentes/catalogo-obligaciones')
.then(({ data }) => setCatalogo(data.data))
.catch(() => {});
}, []);
const handleInit = async () => {
if (!selectedContribuyenteId || !selectedContribuyenteRfc) return;
try {
await apiClient.post(
`/contribuyentes/${selectedContribuyenteId}/obligaciones/init`,
{
rfc: selectedContribuyenteRfc,
regimenes: [],
tieneNomina: false,
}
);
await fetchObligaciones();
invalidateRelated();
} catch (err: unknown) {
const e = err as { response?: { data?: { message?: string } } };
alert(e.response?.data?.message || 'Error al generar recomendaciones');
}
};
const handleAdd = async () => {
if (!selectedContribuyenteId) return;
try {
if (addMode === 'catalogo' && selectedCatalogoId) {
const item = catalogo.find((c) => c.id === selectedCatalogoId);
if (!item) return;
await apiClient.post(`/contribuyentes/${selectedContribuyenteId}/obligaciones`, {
catalogoId: item.id,
nombre: item.nombre,
fundamento: item.fundamento,
frecuencia: item.frecuencia,
fechaLimite: item.fechaLimite,
categoria: item.categoria,
});
} else if (addMode === 'custom' && customForm.nombre) {
await apiClient.post(
`/contribuyentes/${selectedContribuyenteId}/obligaciones`,
customForm
);
}
setShowAdd(false);
setSelectedCatalogoId('');
setCustomForm({ nombre: '', fundamento: '', frecuencia: '', fechaLimite: '', categoria: '' });
await fetchObligaciones();
} catch (err: unknown) {
const e = err as { response?: { data?: { message?: string } } };
alert(e.response?.data?.message || 'Error al agregar obligación');
}
};
const invalidateRelated = () => {
queryClient.invalidateQueries({ queryKey: ['alertas-manuales'] });
queryClient.invalidateQueries({ queryKey: ['alertas-automaticas'] });
queryClient.invalidateQueries({ queryKey: ['alertas'] });
queryClient.invalidateQueries({ queryKey: ['eventos'] });
};
const handleRemove = async (id: string) => {
await apiClient.delete(
`/contribuyentes/${selectedContribuyenteId}/obligaciones/${id}`
);
await fetchObligaciones();
invalidateRelated();
};
const handleRestore = async (id: string) => {
await apiClient.post(
`/contribuyentes/${selectedContribuyenteId}/obligaciones/${id}/restore`
);
await fetchObligaciones();
invalidateRelated();
};
if (!selectedContribuyenteId) {
return (
<div className="p-6 max-w-4xl mx-auto">
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold">Selecciona un contribuyente</h3>
<p className="text-sm text-muted-foreground mt-1">
Usa el selector de RFCs en el header para elegir un contribuyente.
</p>
</CardContent>
</Card>
</div>
);
}
const activas = obligaciones.filter((o) => o.activa);
const removidas = obligaciones.filter((o) => !o.activa);
const categorias = [...new Set(activas.map((o) => o.categoria || 'Sin categoría'))];
const frecuenciaBadge = (f: string | null) => {
const colors: Record<string, string> = {
mensual: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
bimestral: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
trimestral: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
anual: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
eventual: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
};
return f ? (
<span className={cn('text-xs px-2 py-0.5 rounded-full', colors[f] || colors['eventual'])}>
{f}
</span>
) : null;
};
return (
<>
<Header title="Obligaciones Fiscales" />
<div className="p-6 max-w-4xl mx-auto space-y-6">
{/* Subtítulo */}
<p className="text-sm text-muted-foreground">
{selectedContribuyenteNombre} {selectedContribuyenteRfc}
</p>
{/* Tabs */}
<div className="flex border-b">
<button
onClick={() => setActiveTab('obligaciones')}
className={cn(
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
activeTab === 'obligaciones'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground',
)}
>
Obligaciones
</button>
<button
onClick={() => setActiveTab('tareas')}
className={cn(
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
activeTab === 'tareas'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground',
)}
>
Tareas
</button>
</div>
{activeTab === 'tareas' ? (
<TareasTab contribuyenteId={selectedContribuyenteId ?? null} />
) : (
<>
<div className="flex items-center justify-end gap-2">
{activas.length === 0 && (
<Button
onClick={handleInit}
variant="outline"
className="flex items-center gap-2"
>
<Sparkles className="h-4 w-4" /> Generar recomendaciones
</Button>
)}
<Button onClick={() => setShowAdd(true)} className="flex items-center gap-2">
<Plus className="h-4 w-4" /> Agregar
</Button>
</div>
{/* Active obligations */}
{loading ? (
<p className="text-muted-foreground">Cargando...</p>
) : activas.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Sparkles className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold">Sin obligaciones configuradas</h3>
<p className="text-sm text-muted-foreground mt-1 mb-4">
Importa las obligaciones desde la Constancia de Situación Fiscal (CSF) o agrega manualmente.
</p>
<Button onClick={handleInit} className="flex items-center gap-2">
<Sparkles className="h-4 w-4" /> Generar recomendaciones
</Button>
</CardContent>
</Card>
) : (
<div className="space-y-6">
{categorias.map((cat) => (
<div key={cat}>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
{cat}
</h2>
<div className="space-y-2">
{activas
.filter((o) => (o.categoria || 'Sin categoría') === cat)
.map((ob) => (
<Card key={ob.id}>
<CardContent className="flex items-center justify-between py-3 px-5">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium text-sm">{ob.nombre}</p>
{frecuenciaBadge(ob.frecuencia)}
{ob.esRecomendada && (
<span className="text-xs text-amber-600 dark:text-amber-400">
Recomendada
</span>
)}
{ob.esCustom && (
<span className="text-xs text-purple-600 dark:text-purple-400">
Custom
</span>
)}
</div>
<div className="flex gap-4 mt-1">
{ob.fundamento && (
<p className="text-xs text-muted-foreground">{ob.fundamento}</p>
)}
{ob.fechaLimite && (
<p className="text-xs text-muted-foreground">
📅 {ob.fechaLimite}
</p>
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemove(ob.id)}
className="text-destructive hover:text-destructive ml-2 shrink-0"
>
<Trash2 className="h-4 w-4" />
</Button>
</CardContent>
</Card>
))}
</div>
</div>
))}
</div>
)}
{/* Removed obligations */}
{removidas.length > 0 && (
<div>
<button
onClick={() => setShowRemoved(!showRemoved)}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronDown
className={cn('h-4 w-4 transition-transform', showRemoved && 'rotate-180')}
/>
{removidas.length} obligaciones desactivadas
</button>
{showRemoved && (
<div className="mt-2 space-y-2">
{removidas.map((ob) => (
<Card key={ob.id} className="opacity-50">
<CardContent className="flex items-center justify-between py-3 px-5">
<div>
<p className="font-medium text-sm line-through">{ob.nombre}</p>
<p className="text-xs text-muted-foreground">{ob.categoria}</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleRestore(ob.id)}
>
<RotateCcw className="h-4 w-4" />
</Button>
</CardContent>
</Card>
))}
</div>
)}
</div>
)}
{/* Add dialog */}
<Dialog open={showAdd} onOpenChange={setShowAdd}>
<DialogContent>
<DialogHeader>
<DialogTitle>Agregar obligación fiscal</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex gap-2">
<Button
variant={addMode === 'catalogo' ? 'default' : 'outline'}
size="sm"
onClick={() => setAddMode('catalogo')}
>
Del catálogo
</Button>
<Button
variant={addMode === 'custom' ? 'default' : 'outline'}
size="sm"
onClick={() => setAddMode('custom')}
>
Personalizada
</Button>
</div>
{addMode === 'catalogo' ? (
<div className="max-h-64 overflow-y-auto space-y-1 border rounded-md p-1">
{catalogo.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
Cargando catálogo...
</p>
) : (
catalogo.map((item) => {
const yaAgregada = obligaciones.some(
(o) => o.catalogoId === item.id && o.activa
);
return (
<button
key={item.id}
disabled={yaAgregada}
onClick={() => setSelectedCatalogoId(item.id)}
className={cn(
'w-full text-left p-3 rounded-md text-sm transition-colors',
yaAgregada
? 'opacity-30 cursor-not-allowed'
: selectedCatalogoId === item.id
? 'bg-primary/10 border border-primary'
: 'hover:bg-accent'
)}
>
<p className="font-medium">{item.nombre}</p>
<p className="text-xs text-muted-foreground">
{item.categoria} · {item.frecuencia} · {item.fechaLimite}
</p>
</button>
);
})
)}
</div>
) : (
<div className="space-y-3">
<div>
<Label>Nombre *</Label>
<Input
value={customForm.nombre}
onChange={(e) =>
setCustomForm((p) => ({ ...p, nombre: e.target.value }))
}
placeholder="Nombre de la obligación"
/>
</div>
<div>
<Label>Fundamento legal</Label>
<Input
value={customForm.fundamento}
onChange={(e) =>
setCustomForm((p) => ({ ...p, fundamento: e.target.value }))
}
placeholder="Art. X LISR"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label>Frecuencia</Label>
<Input
value={customForm.frecuencia}
onChange={(e) =>
setCustomForm((p) => ({ ...p, frecuencia: e.target.value }))
}
placeholder="mensual, anual..."
/>
</div>
<div>
<Label>Fecha límite</Label>
<Input
value={customForm.fechaLimite}
onChange={(e) =>
setCustomForm((p) => ({ ...p, fechaLimite: e.target.value }))
}
placeholder="Día 17 del mes..."
/>
</div>
</div>
<div>
<Label>Categoría</Label>
<Input
value={customForm.categoria}
onChange={(e) =>
setCustomForm((p) => ({ ...p, categoria: e.target.value }))
}
placeholder="Federal mensual, Anual..."
/>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowAdd(false)}>
Cancelar
</Button>
<Button
onClick={handleAdd}
disabled={
addMode === 'catalogo' ? !selectedCatalogoId : !customForm.nombre
}
>
Agregar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,627 @@
'use client';
import { useState, useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Button, Label, Input } from '@horux/shared-ui';
import { useThemeStore } from '@/stores/theme-store';
import { useAuthStore } from '@/stores/auth-store';
import { themes, type ThemeName } from '@/themes';
import { Check, Palette, User, Building, Sidebar, PanelTop, Minimize2, Sparkles, RefreshCw, Scale, Trash2, MapPin, KeyRound, Tags, Receipt, Bell, Package } from 'lucide-react';
import { isGlobalAdminRfc, isDespachoTenant } from '@horux/shared';
import Link from 'next/link';
import { apiClient } from '@/lib/api/client';
import { useBancos, useCreateBanco, useDeleteBanco } from '@/lib/hooks/use-bancos';
import { useTenantViewStore } from '@/stores/tenant-view-store';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
const themeOptions: { name: ThemeName; label: string; description: string; layoutDesc: string; layoutIcon: typeof Sidebar }[] = [
{
name: 'light',
label: 'Light',
description: 'Tema claro profesional',
layoutDesc: 'Sidebar estándar fijo',
layoutIcon: Sidebar,
},
{
name: 'dark',
label: 'Dark',
description: 'Modo oscuro con acentos neón',
layoutDesc: 'Sidebar flotante con efecto glass',
layoutIcon: Sparkles,
},
];
function RegimenesActivosSection() {
const queryClient = useQueryClient();
const [saving, setSaving] = useState(false);
const { viewingTenantId } = useTenantViewStore();
const { selectedContribuyenteId } = useContribuyenteStore();
const user = useAuthStore(s => s.user);
const isDespacho = isDespachoTenant(user?.tenantRfc);
const tenantKey = viewingTenantId || 'own';
const { data: catalogo } = useQuery({
queryKey: ['regimenes-catalogo'],
queryFn: async () => {
const res = await apiClient.get<{ id: number; clave: string; descripcion: string; tipoPersona: string }[]>('/regimenes');
return res.data;
},
});
// Despacho: read régimen from contribuyente; Horux360: from tenant activos
const { data: activos } = useQuery({
queryKey: ['regimenes-activos', tenantKey, selectedContribuyenteId],
queryFn: async () => {
if (isDespacho && selectedContribuyenteId) {
const res = await apiClient.get(`/contribuyentes/${selectedContribuyenteId}`);
const c = res.data;
// Build activos array from regimen_fiscal field (may be comma-separated: "623,606,612")
if (c.regimenFiscal && catalogo) {
const claves = c.regimenFiscal.split(',').map((s: string) => s.trim());
return claves
.map((clave: string) => catalogo.find((cat: any) => cat.clave === clave))
.filter(Boolean)
.map((cat: any) => ({ id: cat.id, clave: cat.clave, descripcion: cat.descripcion }));
}
return [];
}
const res = await apiClient.get<{ id: number; clave: string; descripcion: string }[]>('/regimenes/activos');
return res.data;
},
enabled: !!catalogo,
});
const [selected, setSelected] = useState<Set<number>>(new Set());
useEffect(() => {
if (activos && catalogo) {
const ids = new Set(activos.map(a => catalogo.find(c => c.clave === a.clave)?.id).filter(Boolean) as number[]);
setSelected(ids);
}
}, [activos, catalogo]);
const toggle = (id: number) => {
setSelected(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleSave = async () => {
setSaving(true);
try {
await apiClient.put('/regimenes/activos', { regimenIds: Array.from(selected) });
queryClient.invalidateQueries({ queryKey: ['regimenes-activos', tenantKey, selectedContribuyenteId] });
queryClient.invalidateQueries({ queryKey: ['calendario'] });
queryClient.invalidateQueries({ queryKey: ['regimenes-periodo'] });
} catch {
alert('Error al guardar');
} finally {
setSaving(false);
}
};
if (!catalogo) return null;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Scale className="h-4 w-4" />
Regimenes Fiscales Activos
</CardTitle>
<CardDescription>
Selecciona los regimenes fiscales bajo los que opera tu empresa. Esto afecta el calendario de obligaciones y los filtros disponibles.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-2 md:grid-cols-2">
{catalogo.map(r => (
<button
key={r.id}
onClick={() => toggle(r.id)}
className={`flex items-center gap-3 p-3 rounded-lg border text-left transition-all ${
selected.has(r.id)
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/30'
}`}
>
<div className={`h-5 w-5 rounded border-2 flex items-center justify-center flex-shrink-0 ${
selected.has(r.id) ? 'border-primary bg-primary' : 'border-muted-foreground/30'
}`}>
{selected.has(r.id) && <Check className="h-3 w-3 text-primary-foreground" />}
</div>
<div className="min-w-0">
<span className="text-xs font-mono font-bold text-muted-foreground">{r.clave}</span>
<p className="text-sm truncate">{r.descripcion}</p>
</div>
</button>
))}
</div>
<div className="mt-4 flex items-center justify-between">
<p className="text-xs text-muted-foreground">{selected.size} regimenes seleccionados</p>
<Button onClick={handleSave} disabled={saving} size="sm">
{saving ? 'Guardando...' : 'Guardar'}
</Button>
</div>
</CardContent>
</Card>
);
}
function DomicilioFiscalSection() {
const queryClient = useQueryClient();
const { viewingTenantId } = useTenantViewStore();
const { selectedContribuyenteId } = useContribuyenteStore();
const user = useAuthStore(s => s.user);
const isDespacho = isDespachoTenant(user?.tenantRfc);
const tenantKey = viewingTenantId || 'own';
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
// Despacho: read domicilio from contribuyente; Horux360: read from tenant datos-fiscales
const { data, isLoading } = useQuery({
queryKey: ['datos-fiscales', tenantKey, selectedContribuyenteId],
queryFn: async () => {
if (isDespacho && selectedContribuyenteId) {
const res = await apiClient.get(`/contribuyentes/${selectedContribuyenteId}`);
const c = res.data;
const dom = c.domicilio || {};
return {
codigoPostal: c.codigoPostal || dom.codigoPostal || '',
calle: dom.calle || '',
numExterior: dom.numExterior || '',
numInterior: dom.numInterior || '',
colonia: dom.colonia || '',
ciudad: dom.ciudad || '',
municipio: dom.municipio || '',
estado: dom.estado || '',
telefono: dom.telefono || '',
};
}
const res = await apiClient.get('/facturacion/datos-fiscales');
return res.data;
},
});
const [form, setForm] = useState({
codigoPostal: '', calle: '', numExterior: '', numInterior: '',
colonia: '', ciudad: '', municipio: '', estado: '', telefono: '',
});
useEffect(() => {
if (data) {
setForm({
codigoPostal: data.codigoPostal || '',
calle: data.calle || '',
numExterior: data.numExterior || '',
numInterior: data.numInterior || '',
colonia: data.colonia || '',
ciudad: data.ciudad || '',
municipio: data.municipio || '',
estado: data.estado || '',
telefono: data.telefono || '',
});
}
}, [data]);
const handleSave = async () => {
setSaving(true);
setMsg(null);
try {
if (isDespacho && selectedContribuyenteId) {
// Save domicilio to contribuyente
await apiClient.put(`/contribuyentes/${selectedContribuyenteId}`, {
domicilio: form,
codigoPostal: form.codigoPostal,
});
} else {
await apiClient.put('/facturacion/datos-fiscales', form);
}
queryClient.invalidateQueries({ queryKey: ['datos-fiscales', tenantKey, selectedContribuyenteId] });
setMsg('Datos guardados');
setTimeout(() => setMsg(null), 3000);
} catch {
setMsg('Error al guardar');
} finally {
setSaving(false);
}
};
if (isLoading) return null;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<MapPin className="h-4 w-4" />
Domicilio Fiscal
</CardTitle>
<CardDescription>
Dirección y teléfono de la empresa. Se usa para facturas al público en general.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label>Código Postal</Label>
<Input value={form.codigoPostal} onChange={e => setForm({ ...form, codigoPostal: e.target.value.replace(/\D/g, '').slice(0, 5) })} placeholder="06600" maxLength={5} />
</div>
<div className="space-y-2 md:col-span-2">
<Label>Calle</Label>
<Input value={form.calle} onChange={e => setForm({ ...form, calle: e.target.value })} placeholder="Av. Reforma" />
</div>
<div className="space-y-2">
<Label>Num. Exterior</Label>
<Input value={form.numExterior} onChange={e => setForm({ ...form, numExterior: e.target.value })} placeholder="123" />
</div>
<div className="space-y-2">
<Label>Num. Interior</Label>
<Input value={form.numInterior} onChange={e => setForm({ ...form, numInterior: e.target.value })} placeholder="4A" />
</div>
<div className="space-y-2">
<Label>Colonia</Label>
<Input value={form.colonia} onChange={e => setForm({ ...form, colonia: e.target.value })} placeholder="Juárez" />
</div>
<div className="space-y-2">
<Label>Ciudad</Label>
<Input value={form.ciudad} onChange={e => setForm({ ...form, ciudad: e.target.value })} placeholder="Ciudad de México" />
</div>
<div className="space-y-2">
<Label>Municipio</Label>
<Input value={form.municipio} onChange={e => setForm({ ...form, municipio: e.target.value })} placeholder="Cuauhtémoc" />
</div>
<div className="space-y-2">
<Label>Estado</Label>
<Input value={form.estado} onChange={e => setForm({ ...form, estado: e.target.value })} placeholder="CDMX" />
</div>
<div className="space-y-2">
<Label>Teléfono / Celular</Label>
<Input value={form.telefono} onChange={e => setForm({ ...form, telefono: e.target.value.replace(/[^\d+\-() ]/g, '').slice(0, 20) })} placeholder="+52 55 1234 5678" />
</div>
</div>
<div className="flex items-center justify-between">
{msg && <p className={`text-sm ${msg.includes('Error') ? 'text-red-600' : 'text-green-600'}`}>{msg}</p>}
{!msg && <div />}
<Button onClick={handleSave} disabled={saving} size="sm">
{saving ? 'Guardando...' : 'Guardar'}
</Button>
</div>
</CardContent>
</Card>
);
}
function BancosSection() {
const { data: bancos, isLoading } = useBancos();
const createBanco = useCreateBanco();
const deleteBancoMut = useDeleteBanco();
const [nombre, setNombre] = useState('');
const [terminacion, setTerminacion] = useState('');
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
if (!nombre || !terminacion) return;
try {
await createBanco.mutateAsync({ banco: nombre, terminacionCuenta: terminacion });
setNombre('');
setTerminacion('');
} catch (err: any) {
alert(err.response?.data?.message || 'Error al crear banco');
}
};
const handleDelete = async (id: number) => {
if (!confirm('Eliminar este banco?')) return;
try {
await deleteBancoMut.mutateAsync(id);
} catch (err: any) {
alert(err.response?.data?.message || 'Error al eliminar');
}
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building className="h-5 w-5" />
Bancos
</CardTitle>
<CardDescription>Cuentas bancarias para conciliacion</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
) : bancos && bancos.length > 0 ? (
<div className="divide-y">
{bancos.map((b) => (
<div key={b.id} className="flex items-center justify-between py-2">
<div>
<span className="font-medium">{b.banco}</span>
<span className="text-muted-foreground ml-2">****{b.terminacionCuenta}</span>
</div>
<Button variant="ghost" size="sm" onClick={() => handleDelete(b.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No hay bancos registrados</p>
)}
<form onSubmit={handleAdd} className="flex gap-2 items-end">
<div className="flex-1 space-y-1">
<Label htmlFor="banco-nombre">Banco</Label>
<Input id="banco-nombre" value={nombre} onChange={e => setNombre(e.target.value)} placeholder="BBVA" required />
</div>
<div className="w-32 space-y-1">
<Label htmlFor="banco-term">Terminacion</Label>
<Input id="banco-term" value={terminacion} onChange={e => setTerminacion(e.target.value.replace(/\D/g, '').slice(0, 4))} placeholder="1234" maxLength={4} required />
</div>
<Button type="submit" disabled={createBanco.isPending}>Agregar</Button>
</form>
</CardContent>
</Card>
);
}
export default function ConfiguracionPage() {
const { theme, setTheme } = useThemeStore();
const { user } = useAuthStore();
const { viewingTenantName } = useTenantViewStore();
const { selectedContribuyenteId, selectedContribuyenteRfc, selectedContribuyenteNombre } = useContribuyenteStore();
const empresaNombre = viewingTenantName || user?.tenantName;
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
const isDespacho = isDespachoTenant(user?.tenantRfc);
return (
<>
<Header title="Configuración" />
<main className="p-6 space-y-6">
{/* User Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<User className="h-4 w-4" />
Información del Usuario
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-sm text-muted-foreground">Nombre</p>
<p className="font-medium">{user?.nombre}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Email</p>
<p className="font-medium">{user?.email}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Rol</p>
<p className="font-medium capitalize">{user?.role}</p>
</div>
</div>
</CardContent>
</Card>
{/* Company Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Building className="h-4 w-4" />
Información de la Empresa
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-sm text-muted-foreground">Empresa</p>
<p className="font-medium">{empresaNombre}</p>
</div>
</div>
</CardContent>
</Card>
{/* Contribuyente header — shown when despacho has one selected */}
{isDespacho && selectedContribuyenteId && (
<Card className="bg-primary/5 border-primary/20">
<CardContent className="py-3 px-5 flex items-center gap-2">
<Building className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">Configuración de: {selectedContribuyenteNombre}</span>
<span className="text-xs text-muted-foreground font-mono">{selectedContribuyenteRfc}</span>
</CardContent>
</Card>
)}
{/* Regímenes Fiscales, Domicilio Fiscal, Bancos */}
{(user?.role === 'owner' || user?.role === 'cfo') && (
isDespacho && !selectedContribuyenteId ? (
<Card>
<CardContent className="py-6 text-center text-muted-foreground">
<p>Selecciona un contribuyente en el header para ver su configuración fiscal.</p>
</CardContent>
</Card>
) : (
<>
<RegimenesActivosSection />
<DomicilioFiscalSection />
<BancosSection />
</>
)
)}
{/* SAT Configuration */}
<Link href="/configuracion/sat">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<RefreshCw className="h-4 w-4" />
Sincronizacion SAT
</CardTitle>
<CardDescription>
Configura tu FIEL y la sincronizacion automatica de CFDIs con el SAT
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Descarga automaticamente tus facturas emitidas y recibidas directamente del portal del SAT.
</p>
</CardContent>
</Card>
</Link>
{/* Obligaciones Fiscales */}
{(user?.role === 'owner' || user?.role === 'cfo') && (
<Link href="/configuracion/obligaciones">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Tags className="h-4 w-4" />
Obligaciones Fiscales
</CardTitle>
<CardDescription>
Gestiona las obligaciones fiscales de tus contribuyentes
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Recibe recomendaciones basadas en el régimen fiscal, agrega o elimina obligaciones según las necesidades de cada RFC.
</p>
</CardContent>
</Card>
</Link>
)}
{/* Notificaciones */}
<Link href="/configuracion/notificaciones">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Bell className="h-4 w-4" />
Notificaciones
</CardTitle>
<CardDescription>
Activa o desactiva los correos informativos por contribuyente
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Controla qué correos quieres recibir por cada cliente: documentos subidos, reporte semanal, recordatorios fiscales, vencimiento de suscripción.
</p>
</CardContent>
</Card>
</Link>
{/* Seguridad */}
<Link href="/configuracion/seguridad">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<KeyRound className="h-4 w-4" />
Seguridad
</CardTitle>
<CardDescription>
Cambia tu contraseña y gestiona las sesiones activas de tu cuenta
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Actualiza tu contraseña o cierra todas las sesiones activas si sospechas un acceso no autorizado.
</p>
</CardContent>
</Card>
</Link>
{/* CSD / Facturapi */}
<Link href="/configuracion/csd">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Building className="h-4 w-4" />
Certificado de Sello Digital (CSD)
</CardTitle>
<CardDescription>
Configura tu CSD para emitir facturas electrónicas desde Horux360
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Sube tu certificado y llave privada para timbrar CFDIs directamente desde la plataforma.
</p>
</CardContent>
</Card>
</Link>
{/* Admin global: edición de precios */}
{isGlobalAdmin && (
<>
<Link href="/configuracion/precios-suscripcion">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Tags className="h-4 w-4" />
Precios de suscripciones
</CardTitle>
<CardDescription>
Modifica los precios de los planes Starter, Business, Business + IA y Enterprise.
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Los cambios aplican a suscripciones nuevas y renovaciones. Las vigentes conservan el precio contratado.
</p>
</CardContent>
</Card>
</Link>
<Link href="/configuracion/precios-timbres">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Receipt className="h-4 w-4" />
Precios de timbres adicionales
</CardTitle>
<CardDescription>
Modifica los precios de los paquetes de timbres adicionales (100, 1000, 10000).
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Los cambios aplican a compras nuevas. Los paquetes ya vendidos conservan el precio que pagó el cliente.
</p>
</CardContent>
</Card>
</Link>
<Link href="/configuracion/addons">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Package className="h-4 w-4" />
Add-ons del catálogo
</CardTitle>
<CardDescription>
Gestiona los complementos disponibles (Lolita IA, RFCs extra, módulo IA, etc.).
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Modifica nombre, precio y disponibilidad de cada add-on. Los add-ons ya contratados
conservan el precio al que se cobraron; los cambios aplican a contrataciones nuevas.
</p>
</CardContent>
</Card>
</Link>
</>
)}
</main>
</>
);
}

View File

@@ -0,0 +1,404 @@
'use client';
import { useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, Button } from '@horux/shared-ui';
import { CheckCircle2, Server, Cloud, Clock, ExternalLink } from 'lucide-react';
import { apiClient } from '@/lib/api/client';
import { subscribeMe, changeMyPlan, cancelMySubscription } from '@/lib/api/subscription';
type Despachoplan = 'trial' | 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus' | 'custom';
type PaidPlan = 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus';
interface SubscriptionInfo {
status: string;
plan: string;
amount: number;
currentPeriodStart: string | null;
currentPeriodEnd: string | null;
}
interface PlanInfo {
plan: Despachoplan;
dbMode: string;
trialEndsAt: string | null;
isTrialActive: boolean;
subscription: SubscriptionInfo | null;
}
function daysUntil(isoDate: string): number {
const diff = new Date(isoDate).getTime() - Date.now();
return Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24)));
}
type Frequency = 'monthly' | 'annual';
export default function PlanesDespachoPage() {
const [planInfo, setPlanInfo] = useState<PlanInfo | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<null | PaidPlan | 'cancel'>(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
// bajar friction inicial; el descuento del 17% al pagar anual se
// muestra como CTA secundario.
const [meFreq, setMeFreq] = useState<Frequency>('monthly');
const [mePlusFreq, setMePlusFreq] = useState<Frequency>('monthly');
const fetchPlan = () => {
apiClient.get<PlanInfo>('/despachos/me/plan')
.then(res => setPlanInfo(res.data))
.catch(() => setPlanInfo(null));
};
useEffect(() => {
setLoading(true);
apiClient.get<PlanInfo>('/despachos/me/plan')
.then(res => setPlanInfo(res.data))
.catch(() => setPlanInfo(null))
.finally(() => setLoading(false));
}, []);
const currentPlan = planInfo?.plan ?? null;
const trialDaysLeft = planInfo?.trialEndsAt ? daysUntil(planInfo.trialEndsAt) : 0;
const hasPaidPlan = currentPlan === 'business_control' || currentPlan === 'business_cloud' || currentPlan === 'mi_empresa' || currentPlan === 'mi_empresa_plus';
// Plan Custom: asignado por administrador, sin cobro, sin fecha fin.
// Cuando es activo, ocultamos las cards de planes pagables (no hay opción
// de auto-cambio — el contador debe contactar soporte si quiere cambiar).
const isCustomPlan = currentPlan === 'custom';
/** Resuelve la frecuencia para un plan. Mi Empresa y Mi Empresa+ leen su
* propio toggle; el resto (business_*) siempre annual. */
function frequencyFor(plan: PaidPlan): Frequency {
if (plan === 'mi_empresa') return meFreq;
if (plan === 'mi_empresa_plus') return mePlusFreq;
return 'annual';
}
async function handleContratar(plan: PaidPlan) {
const frequency = frequencyFor(plan);
setBusy(plan);
setMessage(null);
try {
const result = await subscribeMe({ plan, frequency });
window.open(result.paymentUrl, '_blank');
setMessage({ kind: 'ok', text: 'Abrimos el pago de MercadoPago en otra pestana. Al completar regresa aqui.' });
} catch (err: any) {
const msg: string = err?.response?.data?.message || err?.message || '';
if (/Ya existe una suscripci/i.test(msg)) {
// Ya hay sub activa/pendiente en otro plan — tratar como cambio
try {
await changeMyPlan({ plan, frequency });
setMessage({ kind: 'ok', text: 'Cambio de plan programado para el final del periodo actual.' });
fetchPlan();
} catch (changeErr: any) {
setMessage({ kind: 'err', text: changeErr?.response?.data?.message || changeErr?.message || 'Error al cambiar el plan' });
}
} else {
setMessage({ kind: 'err', text: msg || 'Error al contratar el plan' });
}
} finally {
setBusy(null);
}
}
async function handleCancelar() {
if (!confirm('Seguro que quieres cancelar la suscripcion? Conservaras acceso hasta el final del periodo pagado.')) return;
setBusy('cancel');
setMessage(null);
try {
await cancelMySubscription();
setMessage({ kind: 'ok', text: 'Suscripcion cancelada. Acceso activo hasta el final del periodo actual.' });
fetchPlan();
} catch (err: any) {
setMessage({ kind: 'err', text: err?.response?.data?.message || err?.message || 'Error al cancelar' });
} 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">
Plan actual
</div>
);
}
/**
* Toggle binario Mensual/Anual. La opción anual va resaltada con un
* pequeño badge "17%" para enfocar el descuento.
*/
function FrequencyToggle({ value, onChange }: { value: Frequency; onChange: (v: Frequency) => void }) {
return (
<div className="flex bg-muted rounded-lg p-1 text-xs font-medium">
<button
type="button"
onClick={() => onChange('monthly')}
className={`flex-1 py-1.5 rounded-md transition-colors ${value === 'monthly' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
Mensual
</button>
<button
type="button"
onClick={() => onChange('annual')}
className={`flex-1 py-1.5 rounded-md transition-colors flex items-center justify-center gap-1 ${value === 'annual' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
Anual <span className="text-emerald-600 dark:text-emerald-400 text-[10px] font-bold">17%</span>
</button>
</div>
);
}
function PlanActionButton({ plan }: { plan: PaidPlan }) {
const isCurrent = currentPlan === plan;
if (isCurrent) {
return <Button disabled className="w-full">Plan actual</Button>;
}
const label = hasPaidPlan ? 'Cambiar a este plan' : 'Contratar';
return (
<Button
className="w-full"
onClick={() => handleContratar(plan)}
disabled={busy === plan}
>
{busy === plan ? 'Procesando...' : (
<>
<ExternalLink className="h-4 w-4 mr-2" />
{label}
</>
)}
</Button>
);
}
return (
<div className="p-6 max-w-5xl mx-auto space-y-8">
<div className="text-center space-y-2">
<h1 className="text-2xl font-bold">Planes Horux Despachos</h1>
<p className="text-muted-foreground">Tres planes: Mi Empresa para usuarios individuales, Business Control y Enterprise para despachos.</p>
</div>
{/* Banner Custom — plan asignado por admin, sin cobro */}
{!loading && isCustomPlan && (
<div className="flex items-start gap-3 bg-pink-50 dark:bg-pink-950 border border-pink-200 dark:border-pink-800 rounded-lg px-4 py-3 max-w-3xl mx-auto">
<CheckCircle2 className="h-5 w-5 text-pink-600 dark:text-pink-400 flex-shrink-0 mt-0.5" />
<div className="text-sm space-y-0.5">
<div className="font-semibold text-pink-800 dark:text-pink-300">
Plan Custom sin cobro, vigencia indefinida
</div>
<div className="text-pink-700 dark:text-pink-400">
Tu cuenta está bajo un plan especial asignado por tu administrador.
Contacta a soporte si necesitas cambiar de plan.
</div>
</div>
</div>
)}
{/* Trial banner */}
{!loading && planInfo?.isTrialActive && (
<div className="flex items-center gap-3 bg-amber-50 dark:bg-amber-950 border border-amber-200 dark:border-amber-800 rounded-lg px-4 py-3 max-w-3xl mx-auto">
<Clock className="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0" />
<div className="text-sm">
<span className="font-semibold text-amber-800 dark:text-amber-300">Periodo de prueba activo</span>
<span className="text-amber-700 dark:text-amber-400"> {trialDaysLeft} {trialDaysLeft === 1 ? 'dia restante' : 'dias restantes'}</span>
</div>
</div>
)}
{/* Banner de suscripción activa */}
{!loading && planInfo?.subscription && hasPaidPlan && (() => {
const sub = planInfo.subscription;
const periodEndDate = sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null;
const fechaFormato = periodEndDate
? periodEndDate.toLocaleDateString('es-MX', { year: 'numeric', month: 'long', day: 'numeric' })
: null;
const montoFmt = sub.amount.toLocaleString('es-MX');
return (
<div className="flex items-start gap-3 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-lg px-4 py-3 max-w-3xl mx-auto">
<CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
<div className="text-sm space-y-0.5">
<div className="font-semibold text-green-800 dark:text-green-300">
Suscripcion activa {
sub.plan === 'business_control' ? 'Business Control'
: sub.plan === 'business_cloud' ? 'Enterprise'
: sub.plan === 'mi_empresa_plus' ? 'Mi Empresa +'
: 'Mi Empresa'
}
</div>
<div className="text-green-700 dark:text-green-400">
Proxima renovacion{fechaFormato ? ` el ${fechaFormato}` : ''}: <strong>${montoFmt}/año</strong>
</div>
</div>
</div>
);
})()}
{/* Toast de resultado */}
{message && (
<div
className={`max-w-3xl mx-auto 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>
)}
{!isCustomPlan && (
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 max-w-7xl mx-auto">
{/* Mi Empresa */}
<Card className={`relative flex flex-col${currentPlan === 'mi_empresa' ? ' ring-2 ring-green-500' : ''}`}>
{currentPlan === 'mi_empresa' && <ActiveBadge />}
<CardHeader className="text-center pb-2">
<div className="mx-auto bg-emerald-100 dark:bg-emerald-900 rounded-full p-3 w-fit mb-2">
<Cloud className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
</div>
<CardTitle className="text-xl">Mi Empresa</CardTitle>
<p className="text-sm text-muted-foreground">Para una sola empresa</p>
</CardHeader>
<CardContent className="flex flex-col flex-1 gap-4">
<FrequencyToggle value={meFreq} onChange={setMeFreq} />
<div className="text-center">
<div className="text-3xl font-bold">${meFreq === 'monthly' ? '580' : '5,800'}</div>
<p className="text-sm text-muted-foreground">{meFreq === 'monthly' ? 'por mes (IVA incluido)' : 'por año (IVA incluido)'}</p>
{meFreq === 'monthly' ? (
<p className="text-xs text-muted-foreground mt-1">o $5,800/año (ahorras 17%)</p>
) : (
<p className="text-xs text-emerald-600 dark:text-emerald-400 mt-1 font-medium">Pagas 10 meses en lugar de 12</p>
)}
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>1 RFC</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>3 usuarios</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 1,000,000 CFDIs</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Base de datos en la nube</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Dashboard, CFDI, IVA/ISR, alertas, calendario</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Reportes, conciliación, documentos, facturación</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>50 timbres/mes incluidos</span></div>
</div>
<div className="mt-auto"><PlanActionButton plan="mi_empresa" /></div>
</CardContent>
</Card>
{/* Mi Empresa + */}
<Card className={`relative flex flex-col${currentPlan === 'mi_empresa_plus' ? ' ring-2 ring-green-500' : ''}`}>
{currentPlan === 'mi_empresa_plus' && <ActiveBadge />}
<CardHeader className="text-center pb-2">
<div className="mx-auto bg-teal-100 dark:bg-teal-900 rounded-full p-3 w-fit mb-2">
<Cloud className="h-6 w-6 text-teal-600 dark:text-teal-400" />
</div>
<CardTitle className="text-xl">Mi Empresa +</CardTitle>
<p className="text-sm text-muted-foreground">Mi Empresa con API y Lolita IA</p>
</CardHeader>
<CardContent className="flex flex-col flex-1 gap-4">
<FrequencyToggle value={mePlusFreq} onChange={setMePlusFreq} />
<div className="text-center">
<div className="text-3xl font-bold">${mePlusFreq === 'monthly' ? '900' : '9,000'}</div>
<p className="text-sm text-muted-foreground">{mePlusFreq === 'monthly' ? 'por mes (IVA incluido)' : 'por año (IVA incluido)'}</p>
{mePlusFreq === 'monthly' ? (
<p className="text-xs text-muted-foreground mt-1">o $9,000/año (ahorras 17%)</p>
) : (
<p className="text-xs text-emerald-600 dark:text-emerald-400 mt-1 font-medium">Pagas 10 meses en lugar de 12</p>
)}
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>1 RFC</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>3 usuarios</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 1,000,000 CFDIs</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Base de datos en la nube</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Dashboard, CFDI, IVA/ISR, alertas, calendario</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Reportes, conciliación, documentos, facturación</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>50 timbres/mes incluidos</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span><strong>API REST</strong> incluida</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span><strong>Lolita IA</strong> agente fiscal</span></div>
</div>
<div className="mt-auto"><PlanActionButton plan="mi_empresa_plus" /></div>
</CardContent>
</Card>
{/* Business Control */}
<Card className={`relative flex flex-col${currentPlan === 'business_control' ? ' ring-2 ring-green-500' : ' border-primary ring-2 ring-primary/20'}`}>
{currentPlan === 'business_control'
? <ActiveBadge />
: (
<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-xl">Business Control</CardTitle>
<p className="text-sm text-muted-foreground">Tu servidor, tus datos</p>
</CardHeader>
<CardContent className="flex flex-col flex-1 gap-4">
<div className="text-center">
<div className="text-3xl font-bold">$25,850</div>
<p className="text-sm text-muted-foreground">por año (IVA incluido)</p>
<p className="text-xs text-muted-foreground mt-1">+ $45/mes por cada RFC adicional sobre 100</p>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 100 RFCs</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Usuarios ilimitados</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 1,000,000 CFDIs por contribuyente</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Servidor local con backup</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Control total de tus datos</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Dashboard, CFDI, IVA/ISR, alertas, calendario</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Reportes, conciliación, documentos, facturación, API</span></div>
</div>
<div className="mt-auto"><PlanActionButton plan="business_control" /></div>
</CardContent>
</Card>
{/* Enterprise (key interna: business_cloud) */}
<Card className={`relative flex flex-col${currentPlan === 'business_cloud' ? ' ring-2 ring-green-500' : ''}`}>
{currentPlan === 'business_cloud' && <ActiveBadge />}
<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">
<Cloud className="h-6 w-6 text-purple-600 dark:text-purple-400" />
</div>
<CardTitle className="text-xl">Enterprise</CardTitle>
<p className="text-sm text-muted-foreground">Despachos grandes con alto volumen</p>
</CardHeader>
<CardContent className="flex flex-col flex-1 gap-4">
<div className="text-center">
<div className="text-3xl font-bold">$43,000</div>
<p className="text-sm text-muted-foreground">por año (IVA incluido)</p>
<p className="text-xs text-muted-foreground mt-1">+ $45/mes por cada RFC adicional sobre 100</p>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 100 RFCs</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Usuarios ilimitados</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 3,000,000 CFDIs por contribuyente</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Servidor local con backup</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Backups automáticos en la nube</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Dashboard, CFDI, IVA/ISR, alertas, calendario</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Reportes, conciliación, documentos, facturación, API</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Soporte prioritario</span></div>
</div>
<div className="mt-auto"><PlanActionButton plan="business_cloud" /></div>
</CardContent>
</Card>
</div>
)}
{/* Cancelar — visible solo si tiene plan pagable activo */}
{hasPaidPlan && (
<div className="text-center pt-4">
<button
type="button"
onClick={handleCancelar}
disabled={busy === 'cancel'}
className="text-sm text-muted-foreground hover:text-destructive underline underline-offset-4 disabled:opacity-50"
>
{busy === 'cancel' ? 'Cancelando...' : 'Cancelar suscripcion'}
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,135 @@
'use client';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import {
isGlobalAdminRfc,
DESPACHO_PLAN_PRICES,
DESPACHO_PLANS,
DESPACHO_OVERAGE_PRICE_MENSUAL,
type DespachoPaidPlan,
} from '@horux/shared';
import { Tags, ShieldAlert, Info, AlertTriangle } from 'lucide-react';
const PLAN_ORDER: DespachoPaidPlan[] = [
'mi_empresa',
'mi_empresa_plus',
'business_control',
'business_cloud',
];
function fmtCurrency(n: number | null): string {
if (n == null) return '—';
return `$${n.toLocaleString('es-MX')}`;
}
export default function PreciosSuscripcionPage() {
const { user } = useAuthStore();
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
if (!isGlobalAdmin) {
return (
<>
<Header title="Precios de suscripciones" />
<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 admin global puede consultar el catálogo de precios.</p>
</CardContent>
</Card>
</main>
</>
);
}
return (
<>
<Header title="Precios de suscripciones" />
<main className="p-6 space-y-6">
<Card className="bg-muted/30 border-dashed">
<CardContent className="py-4 text-sm flex items-start gap-2">
<Info className="h-4 w-4 text-blue-600 mt-0.5 flex-shrink-0" />
<div>
Los planes despacho están configurados en{' '}
<code className="text-xs bg-muted px-1 py-0.5 rounded">packages/shared/src/constants/despacho-plans.ts</code>.
Para modificar precios, edita ese archivo y redespliega los cambios aplican
a contrataciones nuevas y renovaciones; las suscripciones vigentes
conservan su precio.
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Tags className="h-5 w-5" />
Planes despacho
</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4">Plan</th>
<th className="py-2 pr-4 text-right">Mensual (MXN)</th>
<th className="py-2 pr-4 text-right">Anual primer año</th>
<th className="py-2 pr-4 text-right">Anual renovación</th>
<th className="py-2 pr-4 text-right">RFCs incluidos</th>
<th className="py-2 pr-4 text-right">Timbres/mes</th>
</tr>
</thead>
<tbody>
{PLAN_ORDER.map((plan) => {
const price = DESPACHO_PLAN_PRICES[plan];
const limits = DESPACHO_PLANS[plan];
return (
<tr key={plan} className="border-b last:border-0 hover:bg-muted/40">
<td className="py-3 pr-4 font-medium">{limits.name}</td>
<td className="py-3 pr-4 text-right">
{price.permiteMonthly
? <span className="font-medium">{fmtCurrency(price.monthly)}</span>
: <span className="text-muted-foreground">No aplica</span>}
</td>
<td className="py-3 pr-4 text-right font-medium">{fmtCurrency(price.firstYear)}</td>
<td className="py-3 pr-4 text-right">
{price.firstYear !== price.renewal
? <span className="font-medium">{fmtCurrency(price.renewal)}</span>
: <span className="text-muted-foreground">{fmtCurrency(price.renewal)}</span>}
</td>
<td className="py-3 pr-4 text-right text-muted-foreground">{limits.maxRfcs}</td>
<td className="py-3 pr-4 text-right text-muted-foreground">{limits.timbresIncluidosMes}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
<Card className="bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-900/50">
<CardContent className="py-4 text-sm flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p>
<strong>Cobro adicional por RFC extra:</strong>{' '}
<span className="font-mono">${DESPACHO_OVERAGE_PRICE_MENSUAL}/mes</span> por
cada contribuyente que exceda los 100 incluidos. Solo aplica a
<strong> Business Control</strong> y <strong>Enterprise</strong>; los planes
Mi Empresa tienen límite duro de 1 RFC.
</p>
<p className="text-muted-foreground">
Mi Empresa y Mi Empresa+ permiten facturación mensual o anual; al pagar
anual se cobra el equivalente a 10 meses (descuento del 17%).
</p>
</div>
</CardContent>
</Card>
</main>
</>
);
}

View File

@@ -0,0 +1,206 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, CardDescription, Button, Input } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import { isGlobalAdminRfc } from '@horux/shared';
import {
getPaquetesCatalogoAdmin,
updatePaqueteCatalogo,
type PaqueteCatalogoAdmin,
} from '@/lib/api/facturacion';
import { formatCurrency } from '@/lib/utils';
import { Package, ShieldAlert, Loader2, CheckCircle2, AlertTriangle, Save } from 'lucide-react';
export default function TimbresCatalogoPage() {
const { user } = useAuthStore();
const queryClient = useQueryClient();
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
const { data: catalogo = [], isLoading } = useQuery({
queryKey: ['timbres-paquetes-catalogo-admin'],
queryFn: getPaquetesCatalogoAdmin,
enabled: isGlobalAdmin,
});
if (!isGlobalAdmin) {
return (
<>
<Header title="Catálogo de timbres" />
<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 admin global puede editar el catálogo.</p>
</CardContent>
</Card>
</main>
</>
);
}
return (
<>
<Header title="Catálogo de timbres adicionales" />
<main className="p-6 space-y-6">
<Card className="bg-muted/30 border-dashed">
<CardContent className="py-4 text-sm flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 mt-0.5 flex-shrink-0" />
<div>
Los cambios de precio aplican <strong>sólo a compras nuevas</strong>.
Los paquetes ya vendidos conservan el precio que pagó el cliente (snapshot).
Desactivar un paquete lo oculta del catálogo público pero no afecta
paquetes vigentes.
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Package className="h-4 w-4" />
Paquetes en el catálogo
</CardTitle>
<CardDescription>Edita precio o da de baja. Orden por cantidad ascendente.</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<p className="text-center py-8 text-muted-foreground">Cargando...</p>
) : catalogo.length === 0 ? (
<p className="text-center py-8 text-muted-foreground">No hay paquetes en el catálogo.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 font-medium">Cantidad</th>
<th className="pb-2 font-medium">Precio actual</th>
<th className="pb-2 font-medium">Precio por timbre</th>
<th className="pb-2 font-medium">Estado</th>
<th className="pb-2 font-medium">Última actualización</th>
<th className="pb-2 font-medium text-right">Acciones</th>
</tr>
</thead>
<tbody>
{catalogo.map((p) => (
<PaqueteRow key={p.id} paquete={p} onSaved={() => queryClient.invalidateQueries({ queryKey: ['timbres-paquetes-catalogo-admin'] })} />
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</main>
</>
);
}
function PaqueteRow({ paquete, onSaved }: { paquete: PaqueteCatalogoAdmin; onSaved: () => void }) {
const [editing, setEditing] = useState(false);
const [precio, setPrecio] = useState(paquete.precio.toString());
const [active, setActive] = useState(paquete.active);
const [saved, setSaved] = useState(false);
const mutation = useMutation({
mutationFn: () => updatePaqueteCatalogo(paquete.id, {
precio: Number(precio),
active,
}),
onSuccess: () => {
setEditing(false);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
onSaved();
},
onError: (err: any) => {
alert(err?.response?.data?.message || 'Error al guardar');
},
});
const precioNum = Number(precio);
const precioValido = precioNum > 0 && !isNaN(precioNum);
const hasChanges = Number(precio) !== paquete.precio || active !== paquete.active;
return (
<tr className="border-b last:border-0">
<td className="py-3 font-medium">{paquete.cantidad.toLocaleString('es-MX')}</td>
<td className="py-3">
{editing ? (
<div className="flex items-center gap-1">
<span className="text-muted-foreground">$</span>
<Input
type="number"
step="0.01"
value={precio}
onChange={(e) => setPrecio(e.target.value)}
className="w-28 h-8"
/>
</div>
) : (
<span className="font-medium">{formatCurrency(paquete.precio)}</span>
)}
</td>
<td className="py-3 text-muted-foreground">
{precioValido ? formatCurrency(precioNum / paquete.cantidad) : '—'}
</td>
<td className="py-3">
{editing ? (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={active}
onChange={(e) => setActive(e.target.checked)}
className="h-4 w-4 accent-primary"
/>
<span className="text-xs">{active ? 'Activo' : 'Inactivo'}</span>
</label>
) : (
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium border ${paquete.active ? 'bg-green-50 text-green-700 border-green-200' : 'bg-slate-50 text-slate-500 border-slate-200'}`}>
{paquete.active ? 'Activo' : 'Inactivo'}
</span>
)}
</td>
<td className="py-3 text-xs text-muted-foreground">
{new Date(paquete.updatedAt).toLocaleString('es-MX', { dateStyle: 'short', timeStyle: 'short' })}
</td>
<td className="py-3 text-right">
{saved ? (
<span className="text-xs text-green-700 inline-flex items-center gap-1">
<CheckCircle2 className="h-3 w-3" /> Guardado
</span>
) : editing ? (
<div className="flex items-center gap-1 justify-end">
<Button
size="sm"
variant="outline"
onClick={() => {
setEditing(false);
setPrecio(paquete.precio.toString());
setActive(paquete.active);
}}
disabled={mutation.isPending}
>
Cancelar
</Button>
<Button
size="sm"
onClick={() => mutation.mutate()}
disabled={mutation.isPending || !precioValido || !hasChanges}
>
{mutation.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : <Save className="h-3 w-3 mr-1" />}
Guardar
</Button>
</div>
) : (
<Button size="sm" variant="outline" onClick={() => setEditing(true)}>
Editar
</Button>
)}
</td>
</tr>
);
}

View File

@@ -0,0 +1,197 @@
'use client';
import { useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Button } from '@horux/shared-ui';
import { FielUploadModal } from '@/components/sat/FielUploadModal';
import { SyncStatus } from '@/components/sat/SyncStatus';
import { SyncHistory } from '@/components/sat/SyncHistory';
import { Header } from '@/components/layouts/header';
import { getFielStatus, deleteFiel } from '@/lib/api/fiel';
import { useTenantViewStore } from '@/stores/tenant-view-store';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { useAuthStore } from '@/stores/auth-store';
import { isDespachoTenant } from '@horux/shared';
import { Building2 } from 'lucide-react';
import type { FielStatus } from '@horux/shared';
export default function SatConfigPage() {
const [fielStatus, setFielStatus] = useState<FielStatus | null>(null);
const [loading, setLoading] = useState(true);
const [showUploadModal, setShowUploadModal] = useState(false);
const [deleting, setDeleting] = useState(false);
const { viewingTenantId } = useTenantViewStore();
const { selectedContribuyenteId, selectedContribuyenteRfc, selectedContribuyenteNombre } = useContribuyenteStore();
const user = useAuthStore(s => s.user);
const isDespacho = isDespachoTenant(user?.tenantRfc);
// For despachos, use per-contribuyente FIEL; for Horux360, use tenant-level
const contribId = isDespacho ? selectedContribuyenteId : null;
const fetchFielStatus = async () => {
setLoading(true);
try {
const status = await getFielStatus(contribId);
setFielStatus(status);
} catch (err) {
console.error('Error fetching FIEL status:', err);
setFielStatus({ configured: false });
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchFielStatus();
}, [viewingTenantId, selectedContribuyenteId]);
const handleUploadSuccess = (status: FielStatus) => {
setFielStatus(status);
setShowUploadModal(false);
};
const handleDelete = async () => {
if (!confirm('Estas seguro de eliminar la FIEL? Se detendran las sincronizaciones automaticas.')) {
return;
}
setDeleting(true);
try {
await deleteFiel(contribId);
setFielStatus({ configured: false });
} catch (err) {
console.error('Error deleting FIEL:', err);
} finally {
setDeleting(false);
}
};
if (loading) {
return (
<>
<Header title="Configuración SAT" />
<div className="p-6">
<p>Cargando...</p>
</div>
</>
);
}
return (
<>
<Header title="Configuración SAT" />
<main className="p-6 space-y-6">
{/* Despacho: show which contribuyente or prompt to select */}
{isDespacho && !selectedContribuyenteId && (
<Card className="border-amber-200 bg-amber-50 dark:bg-amber-950/20">
<CardContent className="py-4 flex items-center gap-3">
<Building2 className="h-5 w-5 text-amber-600" />
<p className="text-sm text-amber-800 dark:text-amber-300">Selecciona un contribuyente en el header para ver y configurar su FIEL.</p>
</CardContent>
</Card>
)}
{isDespacho && selectedContribuyenteId && (
<Card className="bg-primary/5 border-primary/20">
<CardContent className="py-3 px-5 flex items-center gap-2">
<Building2 className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">FIEL de: {selectedContribuyenteNombre}</span>
<span className="text-xs text-muted-foreground font-mono">{selectedContribuyenteRfc}</span>
</CardContent>
</Card>
)}
{/* For despachos without RFC selected, hide everything below the banner */}
{isDespacho && !selectedContribuyenteId ? null : (
<>
{/* Estado de la FIEL */}
<Card>
<CardHeader>
<CardTitle>FIEL (e.firma)</CardTitle>
<CardDescription>
Tu firma electronica para autenticarte con el SAT
</CardDescription>
</CardHeader>
<CardContent>
{fielStatus?.configured ? (
<div className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-sm text-muted-foreground">RFC</p>
<p className="font-medium">{fielStatus.rfc}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">No. Serie</p>
<p className="font-medium text-xs">{fielStatus.serialNumber}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Vigente hasta</p>
<p className="font-medium">
{fielStatus.validUntil ? new Date(fielStatus.validUntil).toLocaleDateString('es-MX') : '-'}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Estado</p>
<p className={`font-medium ${fielStatus.isExpired ? 'text-red-500' : 'text-green-500'}`}>
{fielStatus.isExpired ? 'Vencida' : `Valida (${fielStatus.daysUntilExpiration} dias)`}
</p>
</div>
</div>
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => setShowUploadModal(true)}
>
Actualizar FIEL
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleting}
>
{deleting ? 'Eliminando...' : 'Eliminar FIEL'}
</Button>
</div>
</div>
) : (
<div className="space-y-4">
<p className="text-muted-foreground">
No tienes una FIEL configurada. Sube tu certificado y llave privada para habilitar
la sincronizacion automatica de CFDIs con el SAT.
</p>
<Button onClick={() => setShowUploadModal(true)}>
Configurar FIEL
</Button>
</div>
)}
</CardContent>
</Card>
{/* Estado de Sincronizacion */}
<SyncStatus
fielConfigured={fielStatus?.configured || false}
onSyncStarted={fetchFielStatus}
contribuyenteId={contribId}
/>
{/* Historial */}
<SyncHistory
fielConfigured={fielStatus?.configured || false}
contribuyenteId={contribId}
/>
{/* Modal de carga */}
{showUploadModal && (
<FielUploadModal
onSuccess={handleUploadSuccess}
onClose={() => setShowUploadModal(false)}
contribuyenteId={contribId}
/>
)}
</>
)}
</main>
</>
);
}

View File

@@ -0,0 +1,166 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Button, Input, Label } from '@horux/shared-ui';
import { changePassword, logoutAll } from '@/lib/api/auth';
import { useAuthStore } from '@/stores/auth-store';
import { KeyRound, LogOut, Loader2, AlertCircle, CheckCircle2 } from 'lucide-react';
export default function SeguridadPage() {
const router = useRouter();
const { logout } = useAuthStore();
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [changing, setChanging] = useState(false);
const [changeError, setChangeError] = useState<string | null>(null);
const [changeOk, setChangeOk] = useState<string | null>(null);
const [loggingOut, setLoggingOut] = useState(false);
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
setChangeError(null);
setChangeOk(null);
if (newPassword.length < 8) {
setChangeError('La nueva contraseña debe tener al menos 8 caracteres');
return;
}
if (newPassword !== confirmPassword) {
setChangeError('Las contraseñas no coinciden');
return;
}
if (currentPassword === newPassword) {
setChangeError('La nueva contraseña debe ser distinta a la actual');
return;
}
setChanging(true);
try {
const res = await changePassword(currentPassword, newPassword);
setChangeOk(res.message);
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setTimeout(() => {
logout();
router.push('/login');
}, 2500);
} catch (err: any) {
setChangeError(err?.response?.data?.message || 'Error al cambiar contraseña');
} finally {
setChanging(false);
}
};
const handleLogoutAll = async () => {
if (!confirm('Esto cerrará todas tus sesiones activas, incluyendo esta. Tendrás que iniciar sesión de nuevo. ¿Continuar?')) return;
setLoggingOut(true);
try {
await logoutAll();
logout();
router.push('/login');
} catch (err: any) {
alert(err?.response?.data?.message || 'Error al cerrar sesiones');
setLoggingOut(false);
}
};
return (
<>
<Header title="Seguridad" />
<main className="p-6 space-y-6 max-w-3xl">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<KeyRound className="h-4 w-4" />
Cambiar contraseña
</CardTitle>
<CardDescription>
Al cambiar tu contraseña, todas tus sesiones (incluyendo esta) serán cerradas por seguridad.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleChangePassword} className="space-y-4">
<div className="space-y-1">
<Label htmlFor="current">Contraseña actual</Label>
<Input
id="current"
type="password"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
<div className="space-y-1">
<Label htmlFor="new">Nueva contraseña</Label>
<Input
id="new"
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
autoComplete="new-password"
minLength={8}
required
/>
<p className="text-xs text-muted-foreground">Mínimo 8 caracteres.</p>
</div>
<div className="space-y-1">
<Label htmlFor="confirm">Confirmar nueva contraseña</Label>
<Input
id="confirm"
type="password"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
autoComplete="new-password"
required
/>
</div>
{changeError && (
<div className="flex items-start gap-2 text-sm text-red-700 bg-red-50 border border-red-200 rounded px-3 py-2">
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
<span>{changeError}</span>
</div>
)}
{changeOk && (
<div className="flex items-start gap-2 text-sm text-green-700 bg-green-50 border border-green-200 rounded px-3 py-2">
<CheckCircle2 className="h-4 w-4 mt-0.5 flex-shrink-0" />
<span>{changeOk}</span>
</div>
)}
<Button type="submit" disabled={changing || !!changeOk}>
{changing && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Actualizar contraseña
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<LogOut className="h-4 w-4" />
Cerrar todas las sesiones
</CardTitle>
<CardDescription>
Útil si perdiste un dispositivo o sospechas que alguien accedió a tu cuenta. Tendrás que iniciar sesión de nuevo en todos tus dispositivos, incluyendo este.
</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" onClick={handleLogoutAll} disabled={loggingOut}>
{loggingOut && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Cerrar todas mis sesiones
</Button>
</CardContent>
</Card>
</main>
</>
);
}

View File

@@ -0,0 +1,727 @@
'use client';
import { useMemo, useState } from 'react';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, Button, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import { isGlobalAdminRfc } from '@horux/shared';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
import {
useSubscription,
usePaymentHistory,
useGeneratePaymentLink,
usePlans,
useStartTrial,
useSubscribeMe,
useChangeMyPlan,
useCancelMySubscription,
useUpgradeMe,
useCancelPendingUpgrade,
useReactivateMe,
} from '@/lib/hooks/use-subscription';
import {
CreditCard,
Calendar,
CheckCircle,
AlertCircle,
Clock,
XCircle,
ExternalLink,
Loader2,
AlertTriangle,
CalendarClock,
Building,
Sparkles,
ArrowRight,
Gift,
} from 'lucide-react';
// ============================================================================
// Helpers
// ============================================================================
const PLAN_ORDER = ['starter', 'business', 'business_ia', 'enterprise'] as const;
const PLAN_LABELS: Record<string, string> = {
starter: 'Starter',
business: 'Business',
business_ia: 'Business + IA',
custom: 'Custom',
enterprise: 'Enterprise',
};
const PLAN_FEATURES: Record<string, string[]> = {
starter: ['Dashboard básico', 'CFDI manual', 'Cálculo IVA/ISR'],
business: ['Todo Starter', '50 CFDIs / mes', '3 usuarios', 'Reportes avanzados', 'Alertas fiscales', 'Conciliación bancaria', 'Sincronización SAT', 'Documentos (Opinión Cumplimiento)'],
business_ia: ['Todo Business', 'Lolita — agente IA fiscal'],
enterprise: ['Todo Business', 'Lolita — agente IA fiscal', '100 CFDIs / mes', 'Usuarios ilimitados', 'API de integración'],
};
const statusConfig: Record<string, { label: string; color: string; bgColor: string; icon: typeof CheckCircle }> = {
authorized: { label: 'Activa', color: 'text-green-700', bgColor: 'bg-green-50 border-green-200', icon: CheckCircle },
trial: { label: 'Prueba gratis', color: 'text-blue-700', bgColor: 'bg-blue-50 border-blue-200', icon: Sparkles },
trial_expired: { label: 'Prueba vencida', color: 'text-red-700', bgColor: 'bg-red-50 border-red-200', icon: XCircle },
trial_converted: { label: 'Prueba convertida', color: 'text-green-700', bgColor: 'bg-green-50 border-green-200', icon: CheckCircle },
pending: { label: 'Pendiente de pago', color: 'text-yellow-700', bgColor: 'bg-yellow-50 border-yellow-200', icon: Clock },
paused: { label: 'Pausada', color: 'text-orange-700', bgColor: 'bg-orange-50 border-orange-200', icon: AlertCircle },
cancelled: { label: 'Cancelada', color: 'text-red-700', bgColor: 'bg-red-50 border-red-200', icon: XCircle },
};
function getDaysUntil(dateStr: string | null | undefined): number | null {
if (!dateStr) return null;
const diff = new Date(dateStr).getTime() - Date.now();
return Math.ceil(diff / (1000 * 60 * 60 * 24));
}
function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return '—';
return new Date(dateStr).toLocaleDateString('es-MX', { day: 'numeric', month: 'long', year: 'numeric' });
}
function formatAmount(amount: number | string): string {
return `$${Number(amount).toLocaleString('es-MX', { minimumFractionDigits: 0, maximumFractionDigits: 2 })} MXN`;
}
// ============================================================================
// Plan Grid — componente reusable para picker y modal
// ============================================================================
interface PlanGridProps {
frequency: 'monthly' | 'annual';
selectedPlan: string | null;
currentPlan?: string | null; // resalta el plan actual cuando está en modal
onSelect: (plan: string) => void;
prices: Array<{ plan: string; frequency: string; amount: string }>;
}
function PlanGrid({ frequency, selectedPlan, currentPlan, onSelect, prices }: PlanGridProps) {
const priceByPlan = useMemo(() => {
const m = new Map<string, number>();
for (const p of prices) {
if (p.frequency === frequency) m.set(p.plan, Number(p.amount));
}
return m;
}, [prices, frequency]);
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{PLAN_ORDER.map((plan) => {
const price = priceByPlan.get(plan) ?? 0;
const isSelected = selectedPlan === plan;
const isCurrent = currentPlan === plan;
const features = PLAN_FEATURES[plan] || [];
return (
<button
key={plan}
type="button"
onClick={() => onSelect(plan)}
className={`text-left rounded-lg border-2 p-5 transition-all ${
isSelected
? 'border-primary bg-primary/5 shadow-md'
: 'border-border hover:border-primary/50 hover:bg-muted/40'
} ${isCurrent ? 'ring-2 ring-blue-400 ring-offset-2 ring-offset-background' : ''}`}
>
<div className="flex items-baseline justify-between mb-3">
<h3 className="font-bold text-lg">{PLAN_LABELS[plan]}</h3>
{isCurrent && (
<span className="text-xs font-medium text-blue-700 bg-blue-50 px-2 py-0.5 rounded-full">Actual</span>
)}
</div>
<div className="mb-4">
<span className="text-3xl font-bold">{formatAmount(price)}</span>
<span className="text-sm text-muted-foreground ml-1">/ {frequency === 'monthly' ? 'mes' : 'año'}</span>
</div>
<ul className="space-y-1.5 text-sm text-muted-foreground">
{features.map((f) => (
<li key={f} className="flex items-start gap-1.5">
<CheckCircle className="h-3.5 w-3.5 text-green-600 mt-0.5 shrink-0" />
<span>{f}</span>
</li>
))}
</ul>
</button>
);
})}
</div>
);
}
// ============================================================================
// Admin global: vista de todas las suscripciones
// ============================================================================
// Edición de precios de planes movida a /configuracion/precios-suscripcion.
function AdminGlobalSubscriptions() {
const { data: subscriptions, isLoading } = useQuery({
queryKey: ['all-subscriptions'],
queryFn: () => apiClient.get('/subscriptions').then(r => r.data),
});
if (isLoading) return <div className="text-center py-8 text-muted-foreground">Cargando...</div>;
const subs = (subscriptions || []) as any[];
const activas = subs.filter((s: any) => s.status === 'authorized' || s.status === 'active');
const pendientes = subs.filter((s: any) => s.status === 'pending');
const canceladas = subs.filter((s: any) => s.status === 'cancelled' || s.status === 'paused');
return (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-4">
<Card><CardContent className="pt-6"><div className="flex items-center gap-3"><div className="p-2 bg-primary/10 rounded-lg"><Building className="h-5 w-5 text-primary" /></div><div><p className="text-2xl font-bold">{subs.length}</p><p className="text-xs text-muted-foreground">Total</p></div></div></CardContent></Card>
<Card><CardContent className="pt-6"><div className="flex items-center gap-3"><div className="p-2 bg-green-100 rounded-lg"><CheckCircle className="h-5 w-5 text-green-600" /></div><div><p className="text-2xl font-bold">{activas.length}</p><p className="text-xs text-muted-foreground">Activas</p></div></div></CardContent></Card>
<Card><CardContent className="pt-6"><div className="flex items-center gap-3"><div className="p-2 bg-yellow-100 rounded-lg"><Clock className="h-5 w-5 text-yellow-600" /></div><div><p className="text-2xl font-bold">{pendientes.length}</p><p className="text-xs text-muted-foreground">Pendientes</p></div></div></CardContent></Card>
<Card><CardContent className="pt-6"><div className="flex items-center gap-3"><div className="p-2 bg-red-100 rounded-lg"><XCircle className="h-5 w-5 text-red-600" /></div><div><p className="text-2xl font-bold">{canceladas.length}</p><p className="text-xs text-muted-foreground">Canceladas</p></div></div></CardContent></Card>
</div>
<Card>
<CardHeader><CardTitle className="text-base">Todas las Suscripciones</CardTitle></CardHeader>
<CardContent>
{subs.length === 0 ? (
<p className="text-center py-8 text-muted-foreground">No hay suscripciones</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4">Cliente</th><th className="py-2 pr-4">RFC</th><th className="py-2 pr-4">Plan</th><th className="py-2 pr-4">Estado</th><th className="py-2 pr-4 text-right">Monto</th><th className="py-2 pr-4">Frecuencia</th><th className="py-2 pr-4">Siguiente pago</th><th className="py-2">Creada</th>
</tr>
</thead>
<tbody>
{subs.map((s: any) => {
const st = statusConfig[s.status] || statusConfig.pending;
const StIcon = st.icon;
return (
<tr key={s.id} className="border-b last:border-b-0 hover:bg-muted/50">
<td className="py-3 pr-4 font-medium">{s.tenant?.nombre || '—'}</td>
<td className="py-3 pr-4 font-mono text-xs">{s.tenant?.rfc || '—'}</td>
<td className="py-3 pr-4"><span className="px-2 py-0.5 rounded text-xs font-medium bg-muted capitalize">{s.plan}</span></td>
<td className="py-3 pr-4"><span className={`inline-flex items-center gap-1 text-xs font-medium ${st.color}`}><StIcon className="h-3 w-3" />{st.label}</span></td>
<td className="py-3 pr-4 text-right font-medium">${Number(s.amount).toLocaleString('es-MX', { minimumFractionDigits: 2 })}</td>
<td className="py-3 pr-4 text-muted-foreground capitalize">{s.frequency}</td>
<td className="py-3 pr-4">{s.currentPeriodEnd ? new Date(s.currentPeriodEnd).toLocaleDateString('es-MX') : '—'}</td>
<td className="py-3 text-muted-foreground">{new Date(s.createdAt).toLocaleDateString('es-MX')}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
// ============================================================================
// Frequency Toggle
// ============================================================================
function FrequencyToggle({ value, onChange }: { value: 'monthly' | 'annual'; onChange: (v: 'monthly' | 'annual') => void }) {
return (
<div className="inline-flex items-center rounded-lg border bg-card p-1 text-sm">
<button type="button" onClick={() => onChange('monthly')} className={`px-4 py-1.5 rounded-md font-medium transition-colors ${value === 'monthly' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}>Mensual</button>
<button type="button" onClick={() => onChange('annual')} className={`px-4 py-1.5 rounded-md font-medium transition-colors ${value === 'annual' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}>Anual <span className="ml-1 text-xs opacity-75">(ahorra 17%)</span></button>
</div>
);
}
// ============================================================================
// Main Page
// ============================================================================
export default function SuscripcionPage() {
const { user } = useAuthStore();
// Admin global ve todas las suscripciones
if (isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles)) {
return (
<>
<Header title="Suscripciones" />
<main className="p-6"><AdminGlobalSubscriptions /></main>
</>
);
}
const { data: subscription, isLoading } = useSubscription(user?.tenantId);
const { data: plans = [] } = usePlans();
const { data: payments } = usePaymentHistory(user?.tenantId);
const startTrial = useStartTrial();
const subscribeMe = useSubscribeMe();
const changePlan = useChangeMyPlan();
const cancelSub = useCancelMySubscription();
const generateLink = useGeneratePaymentLink();
const upgradeMe = useUpgradeMe();
const cancelUpgrade = useCancelPendingUpgrade();
const reactivateSub = useReactivateMe();
const [pickerFrequency, setPickerFrequency] = useState<'monthly' | 'annual'>('monthly');
const [pickerSelected, setPickerSelected] = useState<string | null>(null);
const [changeModalOpen, setChangeModalOpen] = useState(false);
const [changeFreq, setChangeFreq] = useState<'monthly' | 'annual'>('monthly');
const [changeSelected, setChangeSelected] = useState<string | null>(null);
const [cancelModalOpen, setCancelModalOpen] = useState(false);
// Estado derivado
const status = subscription?.status || null;
const daysUntilEnd = getDaysUntil(subscription?.currentPeriodEnd);
const isExpired = daysUntilEnd !== null && daysUntilEnd <= 0;
const isTrial = status === 'trial' && !isExpired;
const isTrialExpired = status === 'trial_expired' || (status === 'trial' && isExpired);
const isCancelledInPeriod = status === 'cancelled' && !isExpired;
const isCancelledExpired = status === 'cancelled' && isExpired;
const isActive = status === 'authorized' && !isExpired;
const isPending = status === 'pending';
const hasUsedTrial = !!subscription && ['trial', 'trial_expired', 'trial_converted'].includes(status || '');
const needsNewSubscription = !subscription || isTrialExpired || isCancelledExpired;
const hasPendingChange = !!subscription?.pendingPlan && !!subscription?.pendingEffectiveAt;
const hasPendingUpgrade = !!subscription?.upgradePreferenceId && !!subscription?.upgradeTargetPlan;
// Handlers
const handleStartTrial = async () => {
if (!pickerSelected) return;
try {
await startTrial.mutateAsync({ plan: pickerSelected, frequency: pickerFrequency });
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al iniciar trial');
}
};
const handleSubscribe = async () => {
if (!pickerSelected) return;
try {
const result = await subscribeMe.mutateAsync({ plan: pickerSelected, frequency: pickerFrequency });
window.open(result.paymentUrl, '_blank');
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al crear suscripción');
}
};
const openChangeModal = () => {
setChangeFreq((subscription?.frequency as 'monthly' | 'annual') || 'monthly');
setChangeSelected(subscription?.plan || null);
setChangeModalOpen(true);
};
// Clasifica el cambio para decidir endpoint: upgrade inmediato (con cobro prorateado)
// vs scheduled change (al próximo período). Upgrade aplica solo si se mantiene la
// frecuencia actual Y el nuevo plan es más caro que el actual para esa frecuencia.
const classifyChange = (plan: string, freq: 'monthly' | 'annual'): 'upgrade' | 'scheduled' | 'noop' => {
if (!subscription) return 'scheduled';
if (plan === subscription.plan && freq === subscription.frequency) return 'noop';
if (freq !== subscription.frequency) return 'scheduled';
const newPrice = Number(plans.find((p) => p.plan === plan && p.frequency === freq)?.amount ?? 0);
const currentPrice = Number(subscription.amount);
return newPrice > currentPrice ? 'upgrade' : 'scheduled';
};
const handleConfirmChange = async () => {
if (!changeSelected) return;
const kind = classifyChange(changeSelected, changeFreq);
if (kind === 'noop') {
setChangeModalOpen(false);
return;
}
try {
if (kind === 'upgrade') {
const result = await upgradeMe.mutateAsync(changeSelected);
setChangeModalOpen(false);
window.open(result.checkoutUrl, '_blank');
} else {
await changePlan.mutateAsync({ plan: changeSelected, frequency: changeFreq });
setChangeModalOpen(false);
}
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al cambiar plan');
}
};
const handleCancelPendingUpgrade = async () => {
try {
await cancelUpgrade.mutateAsync();
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al cancelar upgrade');
}
};
const handleReactivate = async () => {
try {
const result = await reactivateSub.mutateAsync();
window.open(result.paymentUrl, '_blank');
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al reactivar suscripción');
}
};
const handleCancel = async () => {
try {
await cancelSub.mutateAsync();
setCancelModalOpen(false);
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al cancelar');
}
};
const handleGeneratePaymentLink = async () => {
if (!user?.tenantId) return;
try {
const result = await generateLink.mutateAsync(user.tenantId);
window.open(result.paymentUrl, '_blank');
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al generar link');
}
};
// ========================================================================
// Render
// ========================================================================
return (
<>
<Header title="Suscripción" />
<main className="p-6 space-y-6">
{isLoading && (
<Card><CardContent className="py-8 text-center text-muted-foreground">Cargando...</CardContent></Card>
)}
{!isLoading && (
<>
{/* Banners de estado */}
{isTrial && (
<div className="flex items-start gap-3 rounded-lg border border-blue-300 bg-blue-50 p-4">
<Sparkles className="h-5 w-5 text-blue-600 mt-0.5 shrink-0" />
<div className="flex-1">
<p className="font-semibold text-blue-800">Estás en prueba gratuita</p>
<p className="text-sm text-blue-700 mt-1">
Te quedan <strong>{daysUntilEnd} día{daysUntilEnd !== 1 ? 's' : ''}</strong> para probar todas las funciones. Contrata un plan antes del {formatDate(subscription?.currentPeriodEnd)} para continuar sin interrupciones.
</p>
</div>
</div>
)}
{isTrialExpired && (
<div className="flex items-start gap-3 rounded-lg border border-red-300 bg-red-50 p-4">
<AlertTriangle className="h-5 w-5 text-red-600 mt-0.5 shrink-0" />
<div className="flex-1">
<p className="font-semibold text-red-800">Tu prueba gratuita terminó</p>
<p className="text-sm text-red-700 mt-1">Elige un plan abajo para continuar usando Horux360.</p>
</div>
</div>
)}
{isCancelledInPeriod && (
<div className="flex items-start gap-3 rounded-lg border border-orange-300 bg-orange-50 p-4">
<AlertCircle className="h-5 w-5 text-orange-600 mt-0.5 shrink-0" />
<div className="flex-1">
<p className="font-semibold text-orange-800">Suscripción cancelada</p>
<p className="text-sm text-orange-700 mt-1">
Seguirás teniendo acceso hasta el <strong>{formatDate(subscription?.currentPeriodEnd)}</strong>. Puedes reactivarla antes de esa fecha para no perder la continuidad; el primer cobro se hará al iniciar el próximo período.
</p>
<Button size="sm" className="mt-3" onClick={handleReactivate} disabled={reactivateSub.isPending}>
{reactivateSub.isPending ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <ArrowRight className="h-4 w-4 mr-2" />}
Reactivar suscripción
</Button>
</div>
</div>
)}
{isCancelledExpired && (
<div className="flex items-start gap-3 rounded-lg border border-red-300 bg-red-50 p-4">
<XCircle className="h-5 w-5 text-red-600 mt-0.5 shrink-0" />
<div className="flex-1">
<p className="font-semibold text-red-800">Suscripción vencida</p>
<p className="text-sm text-red-700 mt-1">Elige un plan abajo para reactivar tu cuenta.</p>
</div>
</div>
)}
{hasPendingChange && (
<div className="flex items-start gap-3 rounded-lg border border-purple-300 bg-purple-50 p-4">
<CalendarClock className="h-5 w-5 text-purple-600 mt-0.5 shrink-0" />
<div className="flex-1">
<p className="font-semibold text-purple-800">Cambio de plan programado</p>
<p className="text-sm text-purple-700 mt-1">
Tu plan cambiará a <strong>{PLAN_LABELS[subscription!.pendingPlan!]}</strong> ({subscription!.pendingFrequency === 'annual' ? 'anual' : 'mensual'}) el <strong>{formatDate(subscription!.pendingEffectiveAt)}</strong>.
</p>
</div>
</div>
)}
{hasPendingUpgrade && (
<div className="flex items-start gap-3 rounded-lg border border-blue-300 bg-blue-50 p-4">
<ArrowRight className="h-5 w-5 text-blue-600 mt-0.5 shrink-0" />
<div className="flex-1">
<p className="font-semibold text-blue-800">Upgrade pendiente de pago</p>
<p className="text-sm text-blue-700 mt-1">
Estás por cambiar a <strong>{PLAN_LABELS[subscription!.upgradeTargetPlan!]}</strong>. Completa el pago prorateado en MercadoPago para activar el plan nuevo de inmediato.
</p>
<div className="flex gap-2 mt-3">
<Button
variant="outline"
size="sm"
onClick={handleCancelPendingUpgrade}
disabled={cancelUpgrade.isPending}
>
{cancelUpgrade.isPending ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : null}
Cancelar upgrade
</Button>
</div>
</div>
</div>
)}
{isPending && !isExpired && (
<div className="flex items-start gap-3 rounded-lg border border-yellow-300 bg-yellow-50 p-4">
<Clock className="h-5 w-5 text-yellow-600 mt-0.5 shrink-0" />
<div className="flex-1">
<p className="font-semibold text-yellow-800">Pago pendiente</p>
<p className="text-sm text-yellow-700 mt-1">Tu suscripción está creada pero aún no autorizaste el pago en MercadoPago.</p>
<Button variant="default" size="sm" className="mt-3" onClick={handleGeneratePaymentLink} disabled={generateLink.isPending}>
{generateLink.isPending ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <ExternalLink className="h-4 w-4 mr-2" />}
Completar pago
</Button>
</div>
</div>
)}
{/* Current subscription card — solo cuando hay sub activa/en-periodo */}
{subscription && !needsNewSubscription && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<CreditCard className="h-5 w-5" />
Tu Suscripción
</CardTitle>
{status && (
(() => {
const st = statusConfig[status] || statusConfig.pending;
const StIcon = st.icon;
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-sm font-medium border ${st.bgColor} ${st.color}`}>
<StIcon className="h-4 w-4" />
{st.label}
</span>
);
})()
)}
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div>
<p className="text-sm text-muted-foreground">Plan</p>
<p className="text-xl font-bold">{PLAN_LABELS[subscription.plan] || subscription.plan}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Monto</p>
<p className="text-xl font-bold">
{Number(subscription.amount) === 0 ? 'Gratis' : formatAmount(subscription.amount)}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Frecuencia</p>
<p className="text-xl font-bold">{subscription.frequency === 'annual' ? 'Anual' : 'Mensual'}</p>
</div>
</div>
<div className="flex flex-wrap gap-2 pt-4 border-t">
{isTrial && (
<Button onClick={() => setPickerSelected(subscription.plan)}>
<ArrowRight className="h-4 w-4 mr-1" />
Contratar ahora
</Button>
)}
{isCancelledInPeriod && (
<Button onClick={handleReactivate} disabled={reactivateSub.isPending}>
{reactivateSub.isPending ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <ArrowRight className="h-4 w-4 mr-1" />}
Reactivar suscripción
</Button>
)}
{(isActive || isPending) && (
<Button variant="outline" onClick={openChangeModal}>Cambiar plan</Button>
)}
{(isActive || isPending || isTrial) && (
<Button variant="outline" className="text-destructive hover:text-destructive" onClick={() => setCancelModalOpen(true)}>
Cancelar suscripción
</Button>
)}
</div>
</CardContent>
</Card>
)}
{/* Billing period */}
{subscription && subscription.currentPeriodEnd && !needsNewSubscription && (
<Card>
<CardHeader><CardTitle className="flex items-center gap-2"><CalendarClock className="h-5 w-5" />Período de Facturación</CardTitle></CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<p className="text-sm text-muted-foreground">Inicio del período</p>
<p className="font-medium">{formatDate(subscription.currentPeriodStart)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Fin del período</p>
<p className="font-medium">{formatDate(subscription.currentPeriodEnd)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">{isTrial ? 'Termina trial en' : 'Próximo pago'}</p>
{daysUntilEnd !== null && daysUntilEnd > 0 ? (
<p className="font-medium">En {daysUntilEnd} día{daysUntilEnd !== 1 ? 's' : ''}</p>
) : (
<p className="font-medium text-red-600">Vencido</p>
)}
</div>
</div>
</CardContent>
</Card>
)}
{/* Picker — primera vez O después de trial/cancel vencido */}
{needsNewSubscription && (
<Card>
<CardHeader>
<CardTitle>{subscription ? 'Elige un plan para continuar' : 'Elige tu plan'}</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
Todos los planes incluyen acceso completo a la plataforma. Puedes cambiar o cancelar cuando quieras.
</p>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex justify-center">
<FrequencyToggle value={pickerFrequency} onChange={setPickerFrequency} />
</div>
<PlanGrid frequency={pickerFrequency} selectedPlan={pickerSelected} onSelect={setPickerSelected} prices={plans} />
<div className="flex flex-col sm:flex-row gap-3 justify-center pt-4 border-t">
{!hasUsedTrial && (
<Button variant="outline" size="lg" onClick={handleStartTrial} disabled={!pickerSelected || startTrial.isPending} className="flex-1 sm:flex-initial">
{startTrial.isPending ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Gift className="h-4 w-4 mr-2" />}
Probar 30 días gratis
</Button>
)}
<Button size="lg" onClick={handleSubscribe} disabled={!pickerSelected || subscribeMe.isPending} className="flex-1 sm:flex-initial">
{subscribeMe.isPending ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <CreditCard className="h-4 w-4 mr-2" />}
Contratar {pickerSelected ? PLAN_LABELS[pickerSelected] : 'plan'}
</Button>
</div>
{!pickerSelected && (
<p className="text-center text-sm text-muted-foreground">Selecciona un plan arriba para continuar</p>
)}
</CardContent>
</Card>
)}
{/* Payment history — siempre visible si hay pagos */}
{payments && payments.length > 0 && (
<Card>
<CardHeader><CardTitle className="flex items-center gap-2"><Calendar className="h-5 w-5" />Historial de Pagos</CardTitle></CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-3 font-medium text-muted-foreground">Fecha</th>
<th className="text-left py-2 px-3 font-medium text-muted-foreground">Monto</th>
<th className="text-left py-2 px-3 font-medium text-muted-foreground">Estado</th>
<th className="text-left py-2 px-3 font-medium text-muted-foreground">Método</th>
</tr>
</thead>
<tbody>
{payments.map((p) => (
<tr key={p.id} className="border-b last:border-0">
<td className="py-2.5 px-3">{new Date(p.createdAt).toLocaleDateString('es-MX')}</td>
<td className="py-2.5 px-3 font-medium">{formatAmount(p.amount)}</td>
<td className="py-2.5 px-3">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${p.status === 'approved' ? 'bg-green-50 text-green-700' : p.status === 'rejected' ? 'bg-red-50 text-red-700' : 'bg-yellow-50 text-yellow-700'}`}>
{p.status === 'approved' && <CheckCircle className="h-3 w-3" />}
{p.status === 'rejected' && <XCircle className="h-3 w-3" />}
{p.status !== 'approved' && p.status !== 'rejected' && <Clock className="h-3 w-3" />}
{p.status === 'approved' ? 'Aprobado' : p.status === 'rejected' ? 'Rechazado' : 'Pendiente'}
</span>
</td>
<td className="py-2.5 px-3 text-muted-foreground capitalize">{p.paymentMethod === 'bank_transfer' ? 'Transferencia' : p.paymentMethod || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</>
)}
{/* Change plan modal */}
<Dialog open={changeModalOpen} onOpenChange={setChangeModalOpen}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>Cambiar plan</DialogTitle>
<DialogDescription>
Los <strong>upgrades</strong> (plan más caro, misma frecuencia) se cobran ahora por la diferencia prorateada y se activan de inmediato. Los <strong>downgrades</strong> y cambios de frecuencia se aplican al iniciar tu próximo período ({formatDate(subscription?.currentPeriodEnd)}).
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-2">
<div className="flex justify-center">
<FrequencyToggle value={changeFreq} onChange={setChangeFreq} />
</div>
<PlanGrid frequency={changeFreq} selectedPlan={changeSelected} currentPlan={subscription?.plan} onSelect={setChangeSelected} prices={plans} />
{changeSelected && (() => {
const kind = classifyChange(changeSelected, changeFreq);
if (kind === 'upgrade') {
return (
<div className="rounded-md border border-blue-300 bg-blue-50 p-3 text-sm text-blue-800">
<strong>Este cambio es un upgrade.</strong> Al confirmar, abriremos MercadoPago para cobrar el monto prorateado de los días restantes del período actual. El plan nuevo se activa en cuanto se confirma el pago.
</div>
);
}
if (kind === 'scheduled') {
return (
<div className="rounded-md border border-purple-300 bg-purple-50 p-3 text-sm text-purple-800">
Este cambio se aplicará el <strong>{formatDate(subscription?.currentPeriodEnd)}</strong>. Sin cargo adicional ahora.
</div>
);
}
return null;
})()}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setChangeModalOpen(false)}>Cancelar</Button>
<Button onClick={handleConfirmChange} disabled={!changeSelected || changePlan.isPending || upgradeMe.isPending}>
{(changePlan.isPending || upgradeMe.isPending) ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : null}
{changeSelected && classifyChange(changeSelected, changeFreq) === 'upgrade' ? 'Pagar y activar' : 'Programar cambio'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Cancel confirmation modal */}
<Dialog open={cancelModalOpen} onOpenChange={setCancelModalOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>¿Cancelar suscripción?</DialogTitle>
<DialogDescription>
Conservarás acceso a todas las funciones hasta el <strong>{formatDate(subscription?.currentPeriodEnd)}</strong>. Después de esa fecha tendrás que elegir un nuevo plan para seguir usando Horux360.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setCancelModalOpen(false)}>No, mantener</Button>
<Button variant="outline" className="text-destructive hover:text-destructive" onClick={handleCancel} disabled={cancelSub.isPending}>
{cancelSub.isPending ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : null}
, cancelar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</main>
</>
);
}

View File

@@ -0,0 +1,142 @@
'use client';
import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@horux/shared-ui';
import { Sparkles, Check, ExternalLink } from 'lucide-react';
import { useMyAddons, useSubscribeAddon, useCancelAddon } from '@/lib/hooks/use-addons';
/**
* Catálogo de add-ons disponibles **por contribuyente**. No se ofrecen aquí
* los add-ons tenant-level (maxRfcs, timbres, etc.) — esos viven en la
* pantalla de suscripción del despacho.
*/
const ADDONS_POR_CONTRIBUYENTE: Array<{
codename: string;
nombre: string;
descripcion: string;
precio: number;
frecuencia: 'mensual';
}> = [
{
codename: 'lolita_ia_contribuyente',
nombre: 'Lolita IA',
descripcion: 'Agente IA fiscal dedicado al RFC. Responde dudas, sugiere optimizaciones, prepara resúmenes.',
precio: 250,
frecuencia: 'mensual',
},
];
export function AddonsDialog({
target,
onClose,
}: {
target: { id: string; nombre: string } | null;
onClose: () => void;
}) {
const { data, isLoading } = useMyAddons(target?.id);
const subscribeMut = useSubscribeAddon();
const cancelMut = useCancelAddon();
const handleSubscribe = async (codename: string) => {
if (!target) return;
try {
const result = await subscribeMut.mutateAsync({
addonCodename: codename,
contribuyenteId: target.id,
});
if (result.paymentUrl) {
window.open(result.paymentUrl, '_blank');
}
} catch (err: any) {
alert(err.response?.data?.message || err.message || 'Error al contratar add-on');
}
};
const handleCancel = async (addonId: string, nombre: string) => {
if (!confirm(`¿Cancelar ${nombre}? Se deja de cobrar al final del período actual.`)) return;
try {
await cancelMut.mutateAsync(addonId);
} catch (err: any) {
alert(err.response?.data?.message || err.message || 'Error al cancelar');
}
};
const fmtMoney = (n: number) => n.toLocaleString('es-MX', { style: 'currency', currency: 'MXN', minimumFractionDigits: 0 });
const fmtDate = (iso: string | null) => iso ? new Date(iso).toLocaleDateString('es-MX', { day: '2-digit', month: 'short', year: 'numeric' }) : '—';
const activeAddons = data?.addons ?? [];
return (
<Dialog open={!!target} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2"><Sparkles className="h-5 w-5 text-amber-500" /> Add-ons {target?.nombre}</DialogTitle>
</DialogHeader>
<div className="py-4 space-y-4">
<p className="text-sm text-muted-foreground">
Servicios adicionales de cobro mensual que se contratan por contribuyente. El cobro va en un preapproval MercadoPago independiente se puede cancelar sin afectar la licencia anual del despacho.
</p>
{isLoading ? (
<p className="text-sm text-muted-foreground">Cargando add-ons activos...</p>
) : (
<div className="space-y-3">
{ADDONS_POR_CONTRIBUYENTE.map((a) => {
const active = activeAddons.find((x) => x.codename === a.codename);
const isActive = active && (active.status === 'authorized' || active.status === 'pending');
return (
<div key={a.codename} className="border rounded-lg p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold">{a.nombre}</h3>
{isActive && (
<span className={`text-xs px-2 py-0.5 rounded ${active.status === 'authorized' ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700'}`}>
{active.status === 'authorized' ? <><Check className="inline h-3 w-3 mr-1" />Activo</> : 'Pendiente de pago'}
</span>
)}
</div>
<p className="text-sm text-muted-foreground mt-1">{a.descripcion}</p>
<p className="text-sm font-medium mt-2">{fmtMoney(a.precio)}<span className="text-muted-foreground font-normal"> / mes</span></p>
{isActive && active.currentPeriodEnd && (
<p className="text-xs text-muted-foreground mt-1">Próximo cobro: {fmtDate(active.currentPeriodEnd)}</p>
)}
</div>
<div>
{isActive ? (
<Button
variant="outline"
size="sm"
onClick={() => handleCancel(active.id, a.nombre)}
disabled={cancelMut.isPending}
>
Cancelar
</Button>
) : (
<Button
size="sm"
onClick={() => handleSubscribe(a.codename)}
disabled={subscribeMut.isPending}
className="flex items-center gap-1"
>
Contratar <ExternalLink className="h-3 w-3" />
</Button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
<p className="text-xs text-muted-foreground border-t pt-3">
Al contratar abre una pestaña de MercadoPago para autorizar el cobro recurrente. Si no tienes una suscripción activa del despacho, el add-on no podrá crearse.
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cerrar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,156 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Button, Input, Label, Card, CardContent, Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@horux/shared-ui';
import { useContribuyentes, useCreateContribuyente, useUpdateContribuyente, useDeactivateContribuyente } from '@/lib/hooks/use-contribuyentes';
import type { CreateContribuyenteData } from '@/lib/api/contribuyentes';
import { useAuthStore } from '@/stores/auth-store';
import { apiClient } from '@/lib/api/client';
import { Plus, Pencil, Trash2, Building2, Sparkles } from 'lucide-react';
import { AddonsDialog } from './addons-dialog';
const TRIAL_LIMIT_TOOLTIP = 'Límite de contribuyentes para la prueba gratuita, para continuar agregando contribuyentes, selecciona un plan.';
export default function ContribuyentesPage() {
const { user } = useAuthStore();
const { data: contribuyentes, isLoading } = useContribuyentes();
const createMut = useCreateContribuyente();
const updateMut = useUpdateContribuyente();
const deactivateMut = useDeactivateContribuyente();
const [showDialog, setShowDialog] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<CreateContribuyenteData>({ rfc: '', razonSocial: '' });
const [assignSelf, setAssignSelf] = useState(true);
const [addonsTarget, setAddonsTarget] = useState<{ id: string; nombre: string } | null>(null);
// Trial gate: durante el periodo de prueba el despacho no puede agregar más
// de 5 contribuyentes activos. El backend valida también; aquí solo se
// deshabilita el botón con tooltip explicativo.
const { data: planInfo } = useQuery({
queryKey: ['my-plan-info'],
queryFn: () => apiClient.get<{ isTrialActive: boolean }>('/despachos/me/plan').then(r => r.data),
});
const activeCount = (contribuyentes ?? []).filter((c: any) => c.active !== false).length;
const trialAtLimit = (planInfo?.isTrialActive ?? false) && activeCount >= 5;
const resetForm = () => { setForm({ rfc: '', razonSocial: '' }); setAssignSelf(true); setShowDialog(false); setEditingId(null); };
const handleSave = async () => {
try {
if (editingId) {
await updateMut.mutateAsync({ id: editingId, data: form });
} else {
const created = await createMut.mutateAsync({
...form,
supervisorUserId: assignSelf ? user?.id : undefined,
});
// Overage Business Cloud: si el 4º+ RFC disparó un nuevo addon, abre
// MercadoPago para autorizar el cobro recurrente mensual de $45/RFC.
if (created.overage?.action === 'created' && created.overage.paymentUrl) {
alert(`Se agregó un cobro mensual de $45 por contribuyente adicional (${created.overage.overageCount} extra${created.overage.overageCount === 1 ? '' : 's'}). Autoriza el pago en MercadoPago.`);
window.open(created.overage.paymentUrl, '_blank');
} else if (created.overage?.action === 'updated') {
alert(`Se actualizó el cobro mensual de contribuyentes adicionales a ${created.overage.overageCount} extra${created.overage.overageCount === 1 ? '' : 's'} ($${created.overage.overageCount * 45}/mes).`);
}
}
resetForm();
} catch (err: any) { alert(err.response?.data?.message || 'Error'); }
};
const handleDeactivate = async (id: string, rfc: string) => {
if (!confirm(`¿Desactivar contribuyente ${rfc}?`)) return;
try {
const result = await deactivateMut.mutateAsync(id);
if (result.overage?.action === 'cancelled') {
alert('Se canceló el cobro mensual de contribuyente adicional (volviste a los 3 incluidos).');
} else if (result.overage?.action === 'updated') {
alert(`El cobro de contribuyentes adicionales se actualizó a ${result.overage.overageCount} extra${result.overage.overageCount === 1 ? '' : 's'} ($${result.overage.overageCount * 45}/mes).`);
}
} catch (err: any) { alert(err.response?.data?.message || 'Error'); }
};
const openEdit = (c: any) => {
setForm({ rfc: c.rfc, razonSocial: c.nombre });
setEditingId(c.id);
setShowDialog(true);
};
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
<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
onClick={() => { resetForm(); setShowDialog(true); }}
disabled={trialAtLimit}
title={trialAtLimit ? TRIAL_LIMIT_TOOLTIP : undefined}
className="flex items-center gap-2"
>
<Plus className="h-4 w-4" /> Agregar RFC
</Button>
</div>
{isLoading ? <p className="text-muted-foreground">Cargando...</p> : !contribuyentes || contribuyentes.length === 0 ? (
<Card><CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold">Sin contribuyentes</h3>
<p className="text-sm text-muted-foreground mt-1 mb-4">Agrega el primer RFC para empezar.</p>
<Button
onClick={() => { resetForm(); setShowDialog(true); }}
disabled={trialAtLimit}
title={trialAtLimit ? TRIAL_LIMIT_TOOLTIP : undefined}
>
Agregar primer RFC
</Button>
</CardContent></Card>
) : (
<div className="grid gap-3">{contribuyentes.map((c) => (
<Card key={c.id}><CardContent className="flex items-center justify-between py-4 px-6">
<div>
<p className="font-semibold">{c.nombre}</p>
<p className="text-sm text-muted-foreground font-mono">{c.rfc}</p>
{c.regimenFiscal && <p className="text-xs text-muted-foreground mt-1">Régimen: {c.regimenFiscal}</p>}
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => setAddonsTarget({ id: c.id, nombre: c.nombre })} title="Add-ons"><Sparkles className="h-4 w-4" /></Button>
<Button variant="ghost" size="sm" onClick={() => openEdit(c)}><Pencil className="h-4 w-4" /></Button>
<Button variant="ghost" size="sm" onClick={() => handleDeactivate(c.id, c.rfc)} className="text-destructive hover:text-destructive"><Trash2 className="h-4 w-4" /></Button>
</div>
</CardContent></Card>
))}</div>
)}
<AddonsDialog target={addonsTarget} onClose={() => setAddonsTarget(null)} />
<Dialog open={showDialog} onOpenChange={() => resetForm()}>
<DialogContent>
<DialogHeader><DialogTitle>{editingId ? 'Editar contribuyente' : 'Agregar contribuyente'}</DialogTitle></DialogHeader>
<div className="space-y-4 py-4">
<div><Label>RFC</Label><Input value={form.rfc} onChange={(e) => setForm((p) => ({ ...p, rfc: e.target.value }))} placeholder="ABC010203XY1" maxLength={13} disabled={!!editingId} /></div>
<div><Label>Razón social</Label><Input value={form.razonSocial} onChange={(e) => setForm((p) => ({ ...p, razonSocial: e.target.value }))} placeholder="Empresa SA de CV" /></div>
{!editingId && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id="assignSelf"
checked={assignSelf}
onChange={(e) => setAssignSelf(e.target.checked)}
className="h-4 w-4"
/>
<label htmlFor="assignSelf" className="text-sm text-muted-foreground">
Asignarme como supervisor de este RFC
</label>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={resetForm}>Cancelar</Button>
<Button onClick={handleSave} disabled={createMut.isPending || updateMut.isPending}>{editingId ? 'Guardar' : 'Agregar'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,405 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Header } from '@/components/layouts/header';
import { KpiCard } from '@horux/shared-ui';
import { BarChart } from '@/components/charts/bar-chart';
import { Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
import { PeriodSelector, RegimenSelector } from '@horux/shared-ui';
import { useKpis, useIngresosEgresos, useAlertas, useRegimenesDelPeriodo } from '@/lib/hooks/use-dashboard';
import { useAuthStore } from '@/stores/auth-store';
import { isGlobalAdminRfc } from '@horux/shared';
import {
TrendingUp,
TrendingDown,
Wallet,
Receipt,
AlertTriangle,
ShoppingCart,
CheckSquare,
} from 'lucide-react';
import { cn } from '@horux/shared-ui';
import { FiscalDisclaimer } from '@/components/fiscal-disclaimer';
function getMonthRange(year: number, month: number) {
const start = `${year}-${String(month).padStart(2, '0')}-01`;
const lastDay = new Date(year, month, 0).getDate();
const end = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
return { start, end };
}
function shiftDatesOneYear(fechaInicio: string, fechaFin: string, delta: number) {
const s = new Date(fechaInicio + 'T00:00:00');
const e = new Date(fechaFin + 'T00:00:00');
s.setFullYear(s.getFullYear() + delta);
e.setFullYear(e.getFullYear() + delta);
// Ajustar último día del mes si cambió
const lastDay = new Date(e.getFullYear(), e.getMonth() + 1, 0).getDate();
if (e.getDate() > lastDay) e.setDate(lastDay);
return {
fechaInicio: s.toISOString().split('T')[0],
fechaFin: e.toISOString().split('T')[0],
};
}
export default function DashboardPage() {
const router = useRouter();
const { user } = useAuthStore();
// Admin global no opera sobre datos de despacho — su home natural es
// `/clientes` (gestión de tenants). Redirige al primer render.
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
useEffect(() => {
if (isGlobalAdmin) router.replace('/clientes');
}, [isGlobalAdmin, router]);
const now = new Date();
const defaultRange = getMonthRange(now.getFullYear(), now.getMonth() + 1);
const [fechaInicio, setFechaInicio] = useState(defaultRange.start);
const [fechaFin, setFechaFin] = useState(defaultRange.end);
const [regimenSeleccionado, setRegimenSeleccionado] = useState<string | null>(null);
const [conciliacion, setConciliacion] = useState(false);
// Periodo anterior (mismo rango, un año atrás)
const anterior = shiftDatesOneYear(fechaInicio, fechaFin, -1);
// Año del inicio para el chart anual
const añoChart = new Date(fechaInicio + 'T00:00:00').getFullYear();
const mesResumen = new Date(fechaInicio + 'T00:00:00').getMonth() + 1;
const { data: kpis } = useKpis(fechaInicio, fechaFin, conciliacion);
const { data: kpisAnterior } = useKpis(anterior.fechaInicio, anterior.fechaFin, conciliacion);
const { data: chartData } = useIngresosEgresos(añoChart, conciliacion);
const { data: alertas, isLoading: alertasLoading } = useAlertas(5);
const { data: regimenesPeriodo, isLoading: regimenesLoading } = useRegimenesDelPeriodo(fechaInicio, fechaFin, conciliacion);
const handlePeriodChange = (inicio: string, fin: string) => {
setFechaInicio(inicio);
setFechaFin(fin);
};
// Filtrar ingresos por régimen seleccionado
const ingresosDisplay = regimenSeleccionado
? kpis?.ingresosPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: kpis?.ingresos || 0;
const ingresosAnterior = regimenSeleccionado
? kpisAnterior?.ingresosPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: kpisAnterior?.ingresos || 0;
const ingresosVariacion = ingresosAnterior > 0
? Math.round(((ingresosDisplay - ingresosAnterior) / ingresosAnterior) * 10000) / 100
: null;
// Filtrar egresos por régimen seleccionado
const egresosDisplay = regimenSeleccionado
? kpis?.egresosPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: kpis?.egresos || 0;
const egresosAnterior = regimenSeleccionado
? kpisAnterior?.egresosPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: kpisAnterior?.egresos || 0;
const egresosVariacion = egresosAnterior > 0
? Math.round(((egresosDisplay - egresosAnterior) / egresosAnterior) * 10000) / 100
: null;
// Adquisición de mercancías
const adquisicionDisplay = regimenSeleccionado
? kpis?.adquisicionMercanciasPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: kpis?.adquisicionMercancias || 0;
// Filtrar IVA por régimen seleccionado
const ivaDisplay = regimenSeleccionado
? kpis?.ivaBalancePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: kpis?.ivaBalance || 0;
const ivaAnterior = regimenSeleccionado
? kpisAnterior?.ivaBalancePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: kpisAnterior?.ivaBalance || 0;
const ivaVariacion = ivaAnterior !== 0
? Math.round(((ivaDisplay - ivaAnterior) / Math.abs(ivaAnterior)) * 10000) / 100
: null;
const utilidadDisplay = ingresosDisplay - egresosDisplay;
const margenDisplay = ingresosDisplay > 0
? Math.round((utilidadDisplay / ingresosDisplay) * 10000) / 100
: 0;
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
minimumFractionDigits: 0,
}).format(value);
// Helper para construir URLs de drill-down
const drillUrl = (titulo: string, filters: Record<string, string>) => {
const p = new URLSearchParams({ titulo, fechaInicio, fechaFin, status: 'vigente', ...filters });
if (regimenSeleccionado) {
if (filters.type === 'EMITIDO') p.set('regimenEmisor', regimenSeleccionado);
if (filters.type === 'RECIBIDO') p.set('regimenReceptor', regimenSeleccionado);
}
return `/drill-down?${p}`;
};
// Año anterior para labels
const añoAnterior = new Date(anterior.fechaInicio + 'T00:00:00').getFullYear();
// Reset régimen si ya no existe en el periodo
const regimenesDisponibles = regimenesPeriodo || [];
if (regimenSeleccionado && regimenesDisponibles.length > 0 &&
!regimenesDisponibles.find(r => r.clave === regimenSeleccionado)) {
setRegimenSeleccionado(null);
}
return (
<>
<Header title="Dashboard">
<PeriodSelector
fechaInicio={fechaInicio}
fechaFin={fechaFin}
onChange={handlePeriodChange}
/>
</Header>
<main className="p-6 space-y-6">
{/* Filtros */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<RegimenSelector
regimenes={regimenesDisponibles}
selected={regimenSeleccionado}
onChange={setRegimenSeleccionado}
isLoading={regimenesLoading}
/>
<button
onClick={() => setConciliacion(!conciliacion)}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
conciliacion
? 'bg-primary/10 text-primary border border-primary/30'
: 'hover:bg-accent'
)}
>
<CheckSquare className="h-4 w-4" />
Conciliación
</button>
</div>
</div>
{/* KPIs */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<KpiCard
title={regimenSeleccionado ? `Ingresos del Mes (${regimenSeleccionado})` : 'Ingresos del Mes'}
value={ingresosDisplay}
icon={<TrendingUp className="h-4 w-4" />}
trend={ingresosVariacion !== null ? (ingresosVariacion >= 0 ? 'up' : 'down') : 'neutral'}
trendValue={
ingresosVariacion !== null
? `${ingresosVariacion >= 0 ? '+' : ''}${ingresosVariacion}% vs ${añoAnterior}`
: 'Sin datos del periodo anterior'
}
href={drillUrl('Ingresos del Mes - CFDIs', { bucket: 'ingresos' })}
/>
<KpiCard
title={regimenSeleccionado ? `Gastos del Mes (${regimenSeleccionado})` : 'Gastos del Mes'}
value={egresosDisplay}
icon={<TrendingDown className="h-4 w-4" />}
trend={egresosVariacion !== null ? (egresosVariacion >= 0 ? 'up' : 'down') : 'neutral'}
trendValue={
egresosVariacion !== null
? `${egresosVariacion >= 0 ? '+' : ''}${egresosVariacion}% vs ${añoAnterior}`
: 'Sin datos del periodo anterior'
}
href={drillUrl('Gastos del Mes - CFDIs', { bucket: 'gastos' })}
/>
<KpiCard
title="Utilidad"
value={utilidadDisplay}
icon={<Wallet className="h-4 w-4" />}
trend={utilidadDisplay > 0 ? 'up' : 'down'}
trendValue={`${margenDisplay}% margen`}
/>
<KpiCard
title={regimenSeleccionado ? `Balance IVA (${regimenSeleccionado})` : 'Balance IVA'}
value={ivaDisplay}
icon={<Receipt className="h-4 w-4" />}
trend={ivaDisplay > 0 ? 'up' : ivaDisplay < 0 ? 'down' : 'neutral'}
trendValue={ivaDisplay > 0 ? 'Por pagar' : ivaDisplay < 0 ? 'A favor' : 'Neutro'}
subtitle={
ivaVariacion !== null
? `${ivaVariacion >= 0 ? '+' : ''}${ivaVariacion}% vs ${añoAnterior}`
: undefined
}
href={drillUrl('Balance IVA - CFDIs', {})}
/>
</div>
{/* Desglose por régimen */}
{!regimenSeleccionado && kpis && (
(kpis.ingresosPorRegimen.length > 1 || kpis.egresosPorRegimen.length > 1 || kpis.ivaBalancePorRegimen.length > 1) && (
<div className="grid gap-4 md:grid-cols-2">
{kpis.ingresosPorRegimen.length > 1 && (
<Card>
<CardHeader>
<CardTitle className="text-base font-medium">Ingresos por Regimen</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{kpis.ingresosPorRegimen.map((r) => (
<div key={r.regimenClave} className="flex items-center justify-between py-2 border-b last:border-0">
<div className="flex items-center gap-3">
<span className="text-xs font-mono font-bold bg-muted px-2 py-1 rounded">{r.regimenClave}</span>
<span className="text-sm">{r.regimenDescripcion}</span>
</div>
<span className="text-sm font-semibold">{formatCurrency(r.monto)}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{kpis.egresosPorRegimen.length > 1 && (
<Card>
<CardHeader>
<CardTitle className="text-base font-medium">Gastos por Regimen</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{kpis.egresosPorRegimen.map((r) => (
<div key={r.regimenClave} className="flex items-center justify-between py-2 border-b last:border-0">
<div className="flex items-center gap-3">
<span className="text-xs font-mono font-bold bg-muted px-2 py-1 rounded">{r.regimenClave}</span>
<span className="text-sm">{r.regimenDescripcion}</span>
</div>
<span className="text-sm font-semibold">{formatCurrency(r.monto)}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{kpis.ivaBalancePorRegimen.length > 1 && (
<Card>
<CardHeader>
<CardTitle className="text-base font-medium">Balance IVA por Regimen</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{kpis.ivaBalancePorRegimen.map((r) => (
<div key={r.regimenClave} className="flex items-center justify-between py-2 border-b last:border-0">
<div className="flex items-center gap-3">
<span className="text-xs font-mono font-bold bg-muted px-2 py-1 rounded">{r.regimenClave}</span>
<span className="text-sm">{r.regimenDescripcion}</span>
</div>
<span className={`text-sm font-semibold ${r.monto > 0 ? 'text-destructive' : 'text-success'}`}>
{formatCurrency(r.monto)}
</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
))}
{/* Charts and Alerts */}
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<BarChart
title="Ingresos vs Egresos"
data={chartData || []}
/>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base font-medium">
<AlertTriangle className="h-4 w-4" />
Alertas
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{alertasLoading ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
) : alertas?.length === 0 ? (
<p className="text-sm text-muted-foreground">No hay alertas pendientes</p>
) : (
alertas?.map((alerta) => (
<div
key={alerta.id}
className={`p-3 rounded-lg border ${
alerta.prioridad === 'alta'
? 'border-destructive/50 bg-destructive/10'
: 'border-border bg-muted/50'
}`}
>
<p className="text-sm font-medium">{alerta.titulo}</p>
<p className="text-xs text-muted-foreground mt-1">
{alerta.mensaje}
</p>
</div>
))
)}
</CardContent>
</Card>
</div>
{/* Resumen Fiscal */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
<Card>
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">CFDIs Emitidos</p>
<p className="text-2xl font-bold">{
regimenSeleccionado
? kpis?.cfdisEmitidosPorRegimen?.find(r => r.regimen === regimenSeleccionado)?.total || 0
: kpis?.cfdisEmitidos || 0
}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">CFDIs Recibidos</p>
<p className="text-2xl font-bold">{
regimenSeleccionado
? kpis?.cfdisRecibidosPorRegimen?.find(r => r.regimen === regimenSeleccionado)?.total || 0
: kpis?.cfdisRecibidos || 0
}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Adquisición de Mercancías</p>
</div>
<p className="text-2xl font-bold">{formatCurrency(adquisicionDisplay)}</p>
<p className="text-xs text-muted-foreground mt-1">Uso CFDI G01</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">IVA a Favor ({añoChart})</p>
<p className="text-2xl font-bold text-success">
{formatCurrency(kpis?.ivaAFavorAcumulado || 0)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">IVA a Favor Historico</p>
<p className="text-2xl font-bold text-success">
{formatCurrency(kpis?.ivaAFavorHistorico || 0)}
</p>
<p className="text-xs text-muted-foreground mt-1">{añoChart - 5} {añoChart}</p>
</CardContent>
</Card>
</div>
<FiscalDisclaimer />
</main>
</>
);
}

View File

@@ -0,0 +1,211 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } 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 { usePeriodoStore, añoMesFromFechaInicio } from '@/stores/periodo-store';
import { Building2, RefreshCw, Loader2, TrendingUp, FileCheck, DollarSign, AlertTriangle } from 'lucide-react';
interface Stats {
totalContribuyentes: number;
ultimaExtraccion: string | null;
progresoDelMes: number;
declaracionesPresentadas: number;
declaracionesPagadas: number;
declaracionesAtrasadas: number;
tareasAtrasadas: number;
}
export default function DespachoContribuyentesPage() {
const role = useAuthStore(s => s.user?.role);
const enabled = role === 'owner' || role === 'cfo';
const { fechaInicio } = usePeriodoStore();
const { año, mes } = añoMesFromFechaInicio(fechaInicio);
const { data, isLoading } = useQuery<Stats>({
queryKey: ['despacho-contribuyentes-stats', año, mes],
queryFn: async () => {
const { data } = await apiClient.get<Stats>(`/despachos/contribuyentes-stats?año=${año}&mes=${mes}`);
return data;
},
enabled,
});
if (!enabled) {
return (
<>
<Header title="Despacho — Contribuyentes"><PeriodoSelector /></Header>
<main className="p-6 max-w-5xl mx-auto">
<DespachoSubnav />
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
Esta sección solo está disponible para owner.
</CardContent>
</Card>
</main>
</>
);
}
return (
<>
<Header title="Despacho — Contribuyentes"><PeriodoSelector /></Header>
<main className="p-6 max-w-5xl mx-auto">
<DespachoSubnav />
{isLoading ? (
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
Cargando...
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Building2 className="h-4 w-4" />
Contribuyentes dados de alta
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{data?.totalContribuyentes ?? 0}</div>
<CardDescription className="mt-1">
Activos en el despacho
</CardDescription>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<RefreshCw className="h-4 w-4" />
Última extracción SAT
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{data?.ultimaExtraccion
? new Date(data.ultimaExtraccion).toLocaleDateString('es-MX', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
: '—'}
</div>
<CardDescription className="mt-1">
{data?.ultimaExtraccion
? new Date(data.ultimaExtraccion).toLocaleTimeString('es-MX', {
hour: '2-digit',
minute: '2-digit',
})
: 'Sin extracciones registradas'}
</CardDescription>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<TrendingUp className="h-4 w-4" />
Progreso del mes
</CardTitle>
</CardHeader>
<CardContent>
<div className={`text-3xl font-bold ${
(data?.progresoDelMes ?? 0) >= 80 ? 'text-success' :
(data?.progresoDelMes ?? 0) >= 50 ? 'text-amber-600' :
'text-destructive'
}`}>
{data?.progresoDelMes ?? 0}%
</div>
<div className="h-2 mt-2 rounded-full bg-muted overflow-hidden">
<div
className={`h-full transition-all ${
(data?.progresoDelMes ?? 0) >= 80 ? 'bg-success' :
(data?.progresoDelMes ?? 0) >= 50 ? 'bg-amber-500' :
'bg-destructive'
}`}
style={{ width: `${data?.progresoDelMes ?? 0}%` }}
/>
</div>
<CardDescription className="mt-1">
Obligaciones y tareas completadas
</CardDescription>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<FileCheck className="h-4 w-4" />
Declaraciones presentadas
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{data?.declaracionesPresentadas ?? 0}</div>
<CardDescription className="mt-1">
Subidas al sistema este mes
</CardDescription>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<DollarSign className="h-4 w-4" />
Declaraciones pagadas
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{data?.declaracionesPagadas ?? 0}</div>
<CardDescription className="mt-1">
{data?.declaracionesPresentadas
? `${Math.round((data.declaracionesPagadas / data.declaracionesPresentadas) * 100)}% del mes`
: 'Con comprobante de pago'}
</CardDescription>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<AlertTriangle className="h-4 w-4" />
Declaraciones atrasadas
</CardTitle>
</CardHeader>
<CardContent>
<div className={`text-3xl font-bold ${(data?.declaracionesAtrasadas ?? 0) > 0 ? 'text-destructive' : ''}`}>
{data?.declaracionesAtrasadas ?? 0}
</div>
<CardDescription className="mt-1">
De periodos anteriores sin presentar
</CardDescription>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<AlertTriangle className="h-4 w-4" />
Tareas atrasadas
</CardTitle>
</CardHeader>
<CardContent>
<div className={`text-3xl font-bold ${(data?.tareasAtrasadas ?? 0) > 0 ? 'text-destructive' : ''}`}>
{data?.tareasAtrasadas ?? 0}
</div>
<CardDescription className="mt-1">
De periodos anteriores sin completar
</CardDescription>
</CardContent>
</Card>
</div>
)}
</main>
</>
);
}

View File

@@ -0,0 +1,273 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent } 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 { usePeriodoStore, añoMesFromFechaInicio } from '@/stores/periodo-store';
import { User, AlertTriangle, Loader2, ChevronDown, ChevronRight, CheckCircle2 } from 'lucide-react';
interface Miembro {
userId: string;
nombre: string;
email: string;
rol: 'supervisor' | 'auxiliar';
contribuyentes: number;
obligacionesAtrasadas: number;
tareasAtrasadas: number;
totalPendientes: number;
totalPeriodo: number;
completadasPeriodo: number;
avancePct: number | null;
}
interface SupervisorConAuxiliares extends Miembro {
auxiliares: Miembro[];
}
interface EquipoStatsResponse {
supervisores: SupervisorConAuxiliares[];
huerfanos: Miembro[];
}
const ROLES_SUPERVISORY = new Set(['owner', 'cfo', 'supervisor']);
function AvanceBar({ pct }: { pct: number | null }) {
if (pct === null) return <span className="text-xs text-muted-foreground">Sin datos</span>;
const color =
pct >= 80 ? 'bg-success' :
pct >= 50 ? 'bg-amber-500' :
'bg-destructive';
const text =
pct >= 80 ? 'text-success' :
pct >= 50 ? 'text-amber-600' :
'text-destructive';
return (
<div className="flex items-center gap-2 min-w-[140px]">
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
<div className={`h-full transition-all ${color}`} style={{ width: `${pct}%` }} />
</div>
<span className={`text-xs font-medium tabular-nums ${text}`}>{pct}%</span>
</div>
);
}
function AtrasoBadge({ total }: { total: number }) {
if (total === 0) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs text-muted-foreground bg-muted">
<CheckCircle2 className="h-3 w-3" /> Al día
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive">
<AlertTriangle className="h-3 w-3" />
{total}
</span>
);
}
export default function EquipoPage() {
const role = useAuthStore(s => s.user?.role);
const enabled = role ? ROLES_SUPERVISORY.has(role) : false;
const { fechaInicio } = usePeriodoStore();
const { año, mes } = añoMesFromFechaInicio(fechaInicio);
const [expandedId, setExpandedId] = useState<string | null>(null);
const { data, isLoading } = useQuery<EquipoStatsResponse>({
queryKey: ['despacho-equipo-stats', año, mes],
queryFn: async () => {
const { data } = await apiClient.get<EquipoStatsResponse>(
`/despachos/equipo-stats?año=${año}&mes=${mes}`,
);
return data;
},
enabled,
});
if (!enabled) {
return (
<>
<Header title="Despacho — Equipo"><PeriodoSelector /></Header>
<main className="p-6 max-w-6xl mx-auto">
<DespachoSubnav />
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
Esta sección solo está disponible para owner y supervisor.
</CardContent>
</Card>
</main>
</>
);
}
const supervisores = data?.supervisores ?? [];
const huerfanos = data?.huerfanos ?? [];
const sinDatos = !isLoading && supervisores.length === 0 && huerfanos.length === 0;
return (
<>
<Header title="Despacho — Equipo"><PeriodoSelector /></Header>
<main className="p-6 max-w-6xl mx-auto space-y-6">
<DespachoSubnav />
{isLoading ? (
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
Cargando...
</div>
) : sinDatos ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No hay supervisores ni auxiliares en el despacho.
</CardContent>
</Card>
) : (
<>
{supervisores.length > 0 && (
<Card>
<CardContent className="p-0">
<table className="w-full text-sm">
<thead className="border-b bg-muted/50">
<tr>
<th className="w-10"></th>
<th className="text-left px-4 py-3 font-medium">Miembro</th>
<th className="text-center px-3 py-3 font-medium">Contribuyentes</th>
<th className="text-left px-3 py-3 font-medium">Avance del periodo</th>
<th className="text-center px-3 py-3 font-medium">Atrasos</th>
</tr>
</thead>
<tbody>
{supervisores.map(sup => {
const expanded = expandedId === sup.userId;
const tieneAux = sup.auxiliares.length > 0;
return (
<FilaSupervisor
key={sup.userId}
sup={sup}
expanded={expanded}
tieneAux={tieneAux}
onToggle={() => setExpandedId(expanded ? null : sup.userId)}
/>
);
})}
</tbody>
</table>
</CardContent>
</Card>
)}
{huerfanos.length > 0 && (
<div>
<h2 className="text-sm font-semibold mb-2 text-amber-700 dark:text-amber-400 uppercase tracking-wide flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
Auxiliares sin supervisor asignado
</h2>
<Card>
<CardContent className="p-0">
<table className="w-full text-sm">
<thead className="border-b bg-muted/50">
<tr>
<th className="text-left px-4 py-3 font-medium">Auxiliar</th>
<th className="text-center px-3 py-3 font-medium">Contribuyentes</th>
<th className="text-left px-3 py-3 font-medium">Avance del periodo</th>
<th className="text-center px-3 py-3 font-medium">Atrasos</th>
</tr>
</thead>
<tbody>
{huerfanos.map(aux => (
<tr key={aux.userId} className="border-b hover:bg-muted/30">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">{aux.nombre}</div>
<div className="text-xs text-muted-foreground">{aux.email}</div>
</div>
</div>
</td>
<td className="px-3 py-3 text-center text-muted-foreground">{aux.contribuyentes}</td>
<td className="px-3 py-3"><AvanceBar pct={aux.avancePct} /></td>
<td className="px-3 py-3 text-center"><AtrasoBadge total={aux.totalPendientes} /></td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
</div>
)}
</>
)}
</main>
</>
);
}
function FilaSupervisor({
sup, expanded, tieneAux, onToggle,
}: {
sup: SupervisorConAuxiliares;
expanded: boolean;
tieneAux: boolean;
onToggle: () => void;
}) {
return (
<>
<tr
className={`border-b ${tieneAux ? 'cursor-pointer hover:bg-muted/30' : ''} ${expanded ? 'bg-muted/30' : ''}`}
onClick={tieneAux ? onToggle : undefined}
>
<td className="px-2 py-3 text-center">
{tieneAux ? (
expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground inline" /> : <ChevronRight className="h-4 w-4 text-muted-foreground inline" />
) : null}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-primary" />
<div>
<div className="font-medium">{sup.nombre}</div>
<div className="text-xs text-muted-foreground">
{sup.email} · {sup.auxiliares.length} auxiliar{sup.auxiliares.length === 1 ? '' : 'es'}
</div>
</div>
</div>
</td>
<td className="px-3 py-3 text-center text-muted-foreground">{sup.contribuyentes}</td>
<td className="px-3 py-3"><AvanceBar pct={sup.avancePct} /></td>
<td className="px-3 py-3 text-center"><AtrasoBadge total={sup.totalPendientes} /></td>
</tr>
{expanded && sup.auxiliares.map(aux => (
<tr key={aux.userId} className="border-b bg-muted/10">
<td></td>
<td className="px-4 py-2 pl-10">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<div>
<div className="text-sm">{aux.nombre}</div>
<div className="text-xs text-muted-foreground">{aux.email}</div>
</div>
</div>
</td>
<td className="px-3 py-2 text-center text-muted-foreground">{aux.contribuyentes}</td>
<td className="px-3 py-2"><AvanceBar pct={aux.avancePct} /></td>
<td className="px-3 py-2 text-center"><AtrasoBadge total={aux.totalPendientes} /></td>
</tr>
))}
{expanded && sup.auxiliares.length === 0 && (
<tr className="border-b bg-muted/10">
<td></td>
<td colSpan={4} className="px-4 py-3 pl-10 text-xs text-muted-foreground italic">
Sin auxiliares asignados.
</td>
</tr>
)}
</>
);
}

View File

@@ -0,0 +1,199 @@
'use client';
import Link from 'next/link';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent } 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 { useContribuyenteStore } from '@/stores/contribuyente-store';
import { usePeriodoStore, añoMesFromFechaInicio } from '@/stores/periodo-store';
import { Building2, Clock, AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
interface Asignado {
contribuyenteId: string;
rfc: string;
nombre: string;
carteraNombre: string | null;
obligacionesPendientes: number;
obligacionesAtrasadas: number;
obligacionesCompletadas: number;
tareasPendientes: number;
tareasAtrasadas: number;
tareasCompletadas: number;
}
const ROLES_ASIGNADOS = new Set(['owner', 'cfo', 'supervisor', 'auxiliar']);
export default function MisAsignadosPage() {
const role = useAuthStore(s => s.user?.role);
const enabled = role ? ROLES_ASIGNADOS.has(role) : false;
const { setSelectedContribuyente } = useContribuyenteStore();
const { fechaInicio } = usePeriodoStore();
const { año, mes } = añoMesFromFechaInicio(fechaInicio);
const { data, isLoading } = useQuery<Asignado[]>({
queryKey: ['despacho-mis-asignados', año, mes],
queryFn: async () => {
const { data } = await apiClient.get<Asignado[]>(`/despachos/mis-asignados?año=${año}&mes=${mes}`);
return data;
},
enabled,
});
if (!enabled) {
return (
<>
<Header title="Despacho — Mis asignados"><PeriodoSelector /></Header>
<main className="p-6 max-w-6xl mx-auto">
<DespachoSubnav />
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No tienes contribuyentes asignados.
</CardContent>
</Card>
</main>
</>
);
}
const items = data ?? [];
return (
<>
<Header title="Despacho — Mis asignados"><PeriodoSelector /></Header>
<main className="p-6 max-w-6xl mx-auto">
<DespachoSubnav />
{isLoading ? (
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
Cargando...
</div>
) : items.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No tienes contribuyentes asignados todavía. Pídele al owner que te los asigne en su cartera.
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-0">
<table className="w-full text-sm">
<thead className="border-b bg-muted/50">
<tr>
<th className="text-left px-4 py-3 font-medium">Contribuyente</th>
<th className="text-left px-4 py-3 font-medium">Cartera</th>
<th className="text-left px-4 py-3 font-medium w-[180px]">Avance</th>
<th className="text-center px-3 py-3 font-medium" title="Atrasos de periodos anteriores (obligaciones + tareas)">
Atrasos
</th>
<th className="text-center px-3 py-3 font-medium" title="Obligaciones del periodo">
Obl. periodo
</th>
<th className="text-center px-3 py-3 font-medium" title="Tareas del periodo">
Tareas periodo
</th>
</tr>
</thead>
<tbody>
{items.map(it => {
const total =
it.obligacionesPendientes + it.obligacionesAtrasadas + it.obligacionesCompletadas +
it.tareasPendientes + it.tareasAtrasadas + it.tareasCompletadas;
const completadas = it.obligacionesCompletadas + it.tareasCompletadas;
const pct = total > 0 ? Math.round((completadas / total) * 100) : null;
const barColor =
pct === null ? 'bg-muted' :
pct >= 80 ? 'bg-success' :
pct >= 50 ? 'bg-amber-500' :
'bg-destructive';
const totalAtrasos = it.obligacionesAtrasadas + it.tareasAtrasadas;
const tieneAtrasos = totalAtrasos > 0;
return (
<tr
key={it.contribuyenteId}
className={`border-b hover:bg-muted/30 ${tieneAtrasos ? 'bg-red-50/50 dark:bg-red-950/10' : ''}`}
>
<td className="px-4 py-3">
<Link
href="/configuracion/obligaciones"
onClick={() => setSelectedContribuyente(it.contribuyenteId, it.rfc, it.nombre)}
className="hover:underline"
>
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">{it.nombre}</div>
<div className="text-xs font-mono text-muted-foreground">{it.rfc}</div>
</div>
</div>
</Link>
</td>
<td className="px-4 py-3 text-muted-foreground">
{it.carteraNombre ?? '—'}
</td>
<td className="px-4 py-3">
{pct === null ? (
<span className="text-xs text-muted-foreground">Sin datos</span>
) : (
<div className="flex items-center gap-2" title={`${completadas} de ${total} completadas`}>
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
<div
className={`h-full transition-all ${barColor}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className={`text-xs font-medium tabular-nums ${
pct >= 80 ? 'text-success' :
pct >= 50 ? 'text-amber-600' :
'text-destructive'
}`}>
{pct}%
</span>
</div>
)}
</td>
<td className="px-3 py-3 text-center">
{tieneAtrasos ? (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive"
title={`Obligaciones: ${it.obligacionesAtrasadas} · Tareas: ${it.tareasAtrasadas}`}
>
<AlertTriangle className="h-3 w-3" />
{totalAtrasos}
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs text-muted-foreground bg-muted">
<CheckCircle2 className="h-3 w-3" /> Al día
</span>
)}
</td>
<td className="px-3 py-3 text-center" title="Completadas / Pendientes">
<span className="text-success">{it.obligacionesCompletadas}</span>
<span className="text-muted-foreground"> / </span>
<span className={it.obligacionesPendientes > 0 ? 'text-amber-600 font-medium' : 'text-muted-foreground'}>
{it.obligacionesPendientes}
</span>
</td>
<td className="px-3 py-3 text-center" title="Completadas / Pendientes">
<span className="text-success">{it.tareasCompletadas}</span>
<span className="text-muted-foreground"> / </span>
<span className={it.tareasPendientes > 0 ? 'text-amber-600 font-medium' : 'text-muted-foreground'}>
{it.tareasPendientes}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</CardContent>
</Card>
)}
</main>
</>
);
}

View File

@@ -0,0 +1,16 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/auth-store';
import { defaultDespachoPathForRole } from '@/components/despachos/despacho-subnav';
export default function DespachosIndex() {
const router = useRouter();
const role = useAuthStore(s => s.user?.role);
useEffect(() => {
if (!role) return;
router.replace(defaultDespachoPathForRole(role));
}, [role, router]);
return null;
}

View File

@@ -0,0 +1,996 @@
'use client';
import { Fragment, useState } from 'react';
import { useOpiniones, useConsultarOpinion, useDescargarPdf } from '@/lib/hooks/use-documentos';
import {
useDeclaraciones,
useCreateDeclaracion,
useUploadComprobantePago,
useDeleteDeclaracion,
useDownloadDeclaracionPdf,
} from '@/lib/hooks/use-declaraciones';
import { fileToBase64, type Declaracion, type Impuesto, type Periodicidad } from '@/lib/api/declaraciones';
import { useConstancias, useConsultarConstancia, useDescargarConstanciaPdf } from '@/lib/hooks/use-constancias';
import type { Constancia } from '@/lib/api/constancias';
import { useAuthStore } from '@/stores/auth-store';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, Button, Tabs, TabsList, TabsTrigger, TabsContent, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Input, Label } from '@horux/shared-ui';
import {
FileCheck, Download, RefreshCw, Loader2, AlertTriangle, CheckCircle2, XCircle, Clock,
IdCard, FileText, Upload, Plus, Trash2, Receipt, FolderOpen, Tag, Briefcase,
} from 'lucide-react';
import { PapeleriaTab } from '@/components/documentos/papeleria-tab';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as docsApi from '@/lib/api/documentos';
const MESES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
const IMPUESTOS: Impuesto[] = ['IVA', 'ISR', 'IEPS', 'SUELDOS', 'DIOT', 'OTRO'];
const PERIODICIDADES: { value: Periodicidad; label: string }[] = [
{ value: 'mensual', label: 'Mensual' },
{ value: 'bimestral', label: 'Bimestral' },
{ value: 'trimestral', label: 'Trimestral' },
{ value: 'semestral', label: 'Semestral' },
{ value: 'anual', label: 'Anual' },
];
const PERIODICIDAD_LABELS: Record<string, string> = {
mensual: 'Mensual', bimestral: 'Bimestral', trimestral: 'Trimestral',
semestral: 'Semestral', anual: 'Anual',
};
/** Returns period options based on periodicidad. Each option has a value (mes start) and a label. */
function getPeriodOptions(periodicidad: Periodicidad): { value: number; label: string }[] {
switch (periodicidad) {
case 'mensual':
return MESES.map((m, i) => ({ value: i + 1, label: m }));
case 'bimestral':
return [
{ value: 1, label: 'Enero Febrero' },
{ value: 3, label: 'Marzo Abril' },
{ value: 5, label: 'Mayo Junio' },
{ value: 7, label: 'Julio Agosto' },
{ value: 9, label: 'Septiembre Octubre' },
{ value: 11, label: 'Noviembre Diciembre' },
];
case 'trimestral':
return [
{ value: 1, label: 'Enero Marzo' },
{ value: 4, label: 'Abril Junio' },
{ value: 7, label: 'Julio Septiembre' },
{ value: 10, label: 'Octubre Diciembre' },
];
case 'semestral':
return [
{ value: 1, label: 'Enero Junio' },
{ value: 7, label: 'Julio Diciembre' },
];
case 'anual':
return [{ value: 1, label: 'Anual' }];
default:
return MESES.map((m, i) => ({ value: i + 1, label: m }));
}
}
/** Returns a display label for a period (mes) given its periodicidad */
function getPeriodLabel(periodicidad: string, mes: number): string {
const options = getPeriodOptions(periodicidad as Periodicidad);
return options.find(o => o.value === mes)?.label || MESES[mes - 1] || String(mes);
}
const ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar'];
function EstatusBadge({ estatus }: { estatus: string }) {
if (estatus === 'Positiva') return <span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"><CheckCircle2 className="h-3 w-3" /> {estatus}</span>;
if (estatus === 'Negativa') return <span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"><XCircle className="h-3 w-3" /> {estatus}</span>;
return <span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"><AlertTriangle className="h-3 w-3" /> {estatus}</span>;
}
export default function DocumentosPage() {
const user = useAuthStore((s) => s.user);
const canConsultarOpinion = user?.role === 'owner' || user?.role === 'cfo';
const canSeePapeleria = user?.role !== 'cliente';
return (
<>
<Header title="Documentos" />
<main className="p-6">
<Tabs defaultValue="declaraciones" className="space-y-4">
<TabsList>
<TabsTrigger value="opinion"><FileCheck className="h-4 w-4 mr-1.5" /> Opinión de Cumplimiento</TabsTrigger>
<TabsTrigger value="constancia"><IdCard className="h-4 w-4 mr-1.5" /> Constancia de Situación Fiscal</TabsTrigger>
<TabsTrigger value="declaraciones"><FileText className="h-4 w-4 mr-1.5" /> Declaraciones</TabsTrigger>
<TabsTrigger value="extras"><FolderOpen className="h-4 w-4 mr-1.5" /> Extras</TabsTrigger>
{canSeePapeleria && (
<TabsTrigger value="papeleria"><Briefcase className="h-4 w-4 mr-1.5" /> Papelería de Trabajo</TabsTrigger>
)}
</TabsList>
<TabsContent value="opinion"><OpinionTab canConsultar={canConsultarOpinion} /></TabsContent>
<TabsContent value="constancia"><ConstanciaTab /></TabsContent>
<TabsContent value="declaraciones"><DeclaracionesTab /></TabsContent>
<TabsContent value="extras"><ExtrasTab /></TabsContent>
{canSeePapeleria && (
<TabsContent value="papeleria"><PapeleriaTab /></TabsContent>
)}
</Tabs>
</main>
</>
);
}
// ============================================================================
// Opinión de Cumplimiento
// ============================================================================
function OpinionTab({ canConsultar }: { canConsultar: boolean }) {
const { data: opiniones, isLoading, error } = useOpiniones();
const consultar = useConsultarOpinion();
const descargar = useDescargarPdf();
return (
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold flex items-center gap-2"><FileCheck className="h-5 w-5" /> Opinión de Cumplimiento</h2>
{canConsultar && (
<Button onClick={() => consultar.mutate()} disabled={consultar.isPending} size="sm">
{consultar.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" /> Consultando...</> : <><RefreshCw className="h-4 w-4 mr-2" /> Consultar ahora</>}
</Button>
)}
</div>
{consultar.isError && <div className="p-3 mb-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm">Error: {(consultar.error as Error).message}</div>}
{isLoading && <div className="flex items-center justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>}
{error && <div className="p-4 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm">Error al cargar opiniones: {(error as Error).message}</div>}
{!isLoading && !error && opiniones?.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
<FileCheck className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p>No hay opiniones registradas.</p>
<p className="text-sm mt-1">La consulta automática se ejecuta cada semana.</p>
</div>
)}
{!isLoading && opiniones && opiniones.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 font-medium">Fecha de consulta</th>
<th className="pb-2 font-medium">Estatus</th>
<th className="pb-2 font-medium">Folio</th>
<th className="pb-2 font-medium">RFC</th>
<th className="pb-2 font-medium text-right">PDF</th>
</tr>
</thead>
<tbody className="divide-y">
{opiniones.map((op) => (
<tr key={op.id} className="hover:bg-muted/50">
<td className="py-3"><div className="flex items-center gap-2"><Clock className="h-4 w-4 text-muted-foreground" />{new Date(op.fechaConsulta).toLocaleDateString('es-MX', { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</div></td>
<td className="py-3"><EstatusBadge estatus={op.estatus} /></td>
<td className="py-3 font-mono text-xs">{op.folio}</td>
<td className="py-3 font-mono text-xs">{op.rfc}</td>
<td className="py-3 text-right"><Button variant="ghost" size="sm" onClick={() => descargar.mutate(op.id)} disabled={descargar.isPending}><Download className="h-3.5 w-3.5 mr-1" /> Descargar</Button></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
);
}
// ============================================================================
// Constancia de Situación Fiscal
// ============================================================================
function ConstanciaTab() {
const user = useAuthStore((s) => s.user);
const canConsultar = user?.role === 'owner' || user?.role === 'cfo';
const { data: constancias, isLoading, error } = useConstancias();
const consultar = useConsultarConstancia();
const descargar = useDescargarConstanciaPdf();
const [expandedId, setExpandedId] = useState<number | null>(null);
const latest = constancias?.[0];
return (
<div className="space-y-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4 gap-2 flex-wrap">
<div>
<h2 className="text-lg font-semibold flex items-center gap-2"><IdCard className="h-5 w-5" /> Constancia de Situación Fiscal</h2>
<p className="text-sm text-muted-foreground mt-0.5">Descarga mensual automática del SAT el día 1. También se actualizan el domicilio fiscal y los regímenes activos del tenant.</p>
</div>
{canConsultar && (
<Button onClick={() => consultar.mutate()} disabled={consultar.isPending} size="sm">
{consultar.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" /> Consultando...</> : <><RefreshCw className="h-4 w-4 mr-2" /> Consultar ahora</>}
</Button>
)}
</div>
{consultar.isError && <div className="p-3 mb-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm">Error: {(consultar.error as Error).message}</div>}
{isLoading && <div className="flex items-center justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>}
{error && <div className="p-4 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm">Error: {(error as Error).message}</div>}
{!isLoading && !error && constancias?.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
<IdCard className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p>No hay constancias registradas.</p>
<p className="text-sm mt-1">La consulta automática se ejecuta el 1° de cada mes.</p>
</div>
)}
{latest && (
<ConstanciaDetalle datos={latest.datos} />
)}
</CardContent>
</Card>
{constancias && constancias.length > 0 && (
<Card>
<CardContent className="pt-6">
<h3 className="text-md font-semibold mb-3">Historial (últimas {constancias.length})</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-2 font-medium">Fecha de consulta</th>
<th className="pb-2 pr-2 font-medium">Estatus</th>
<th className="pb-2 pr-2 font-medium">RFC</th>
<th className="pb-2 pr-2 font-medium">Régimenes activos</th>
<th className="pb-2 pr-2 font-medium text-right">Acciones</th>
</tr>
</thead>
<tbody className="divide-y">
{constancias.map((c) => {
const activos = c.datos.regimenes.filter(r => !r.fechaFin).length;
const expanded = expandedId === c.id;
return (
<Fragment key={c.id}>
<tr className="hover:bg-muted/50">
<td className="py-3 pr-2"><div className="flex items-center gap-2"><Clock className="h-4 w-4 text-muted-foreground" />{new Date(c.fechaConsulta).toLocaleDateString('es-MX', { year: 'numeric', month: 'long', day: 'numeric' })}</div></td>
<td className="py-3 pr-2">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${c.estatusPadron === 'ACTIVO' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'}`}>
{c.estatusPadron || '—'}
</span>
</td>
<td className="py-3 pr-2 font-mono text-xs">{c.rfc}</td>
<td className="py-3 pr-2 text-xs">{activos}</td>
<td className="py-3 pr-2 text-right">
<Button variant="ghost" size="sm" onClick={() => setExpandedId(expanded ? null : c.id)}>
{expanded ? 'Ocultar' : 'Ver datos'}
</Button>
<Button variant="ghost" size="sm" onClick={() => descargar.mutate(c.id)} disabled={descargar.isPending}>
<Download className="h-3.5 w-3.5 mr-1" /> PDF
</Button>
</td>
</tr>
{expanded && (
<tr><td colSpan={5} className="p-0 bg-muted/30"><div className="p-4"><ConstanciaDetalle datos={c.datos} /></div></td></tr>
)}
</Fragment>
);
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
);
}
function ConstanciaDetalle({ datos }: { datos: Constancia['datos'] }) {
const d = datos.domicilio;
const domicilio = [
[d.tipoVialidad, d.nombreVialidad].filter(Boolean).join(' '),
d.numeroExterior && `Ext. ${d.numeroExterior}`,
d.numeroInterior && d.numeroInterior.toUpperCase() !== 'SIN NUMERO' && `Int. ${d.numeroInterior}`,
d.colonia,
[d.codigoPostal, d.localidad].filter(Boolean).join(' '),
[d.municipio, d.entidadFederativa].filter(Boolean).join(', '),
].filter(Boolean).join(' · ');
const regimenesActivos = datos.regimenes.filter(r => !r.fechaFin);
return (
<div className="grid md:grid-cols-2 gap-4 text-sm">
<div>
<h4 className="text-xs font-semibold uppercase text-muted-foreground mb-2">Identificación</h4>
<dl className="space-y-1">
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">RFC</dt><dd className="font-mono">{datos.rfc}</dd></div>
{datos.curp && <div className="flex justify-between gap-2"><dt className="text-muted-foreground">CURP</dt><dd className="font-mono">{datos.curp}</dd></div>}
{datos.razonSocial && <div className="flex justify-between gap-2"><dt className="text-muted-foreground">Razón social</dt><dd className="text-right">{datos.razonSocial}</dd></div>}
{!datos.razonSocial && (datos.nombre || datos.primerApellido) && (
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">Nombre</dt><dd className="text-right">{[datos.nombre, datos.primerApellido, datos.segundoApellido].filter(Boolean).join(' ')}</dd></div>
)}
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">Estatus</dt><dd>{datos.estatusPadron}</dd></div>
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">Inicio de operaciones</dt><dd>{datos.fechaInicioOperaciones}</dd></div>
</dl>
</div>
<div>
<h4 className="text-xs font-semibold uppercase text-muted-foreground mb-2">Domicilio fiscal</h4>
<p>{domicilio || '—'}</p>
</div>
<div className="md:col-span-2">
<h4 className="text-xs font-semibold uppercase text-muted-foreground mb-2">Regímenes activos ({regimenesActivos.length})</h4>
{regimenesActivos.length === 0 ? (
<p className="text-muted-foreground">Sin regímenes activos</p>
) : (
<ul className="space-y-1">
{regimenesActivos.map((r, i) => (
<li key={i} className="flex justify-between gap-2">
<span>{r.nombre}</span>
<span className="text-xs text-muted-foreground">desde {r.fechaInicio}</span>
</li>
))}
</ul>
)}
</div>
{datos.obligaciones.length > 0 && (
<div className="md:col-span-2">
<h4 className="text-xs font-semibold uppercase text-muted-foreground mb-2">Obligaciones ({datos.obligaciones.filter(o => !o.fechaFin).length} activas)</h4>
<ul className="space-y-1">
{datos.obligaciones.filter(o => !o.fechaFin).map((o, i) => (
<li key={i} className="text-xs">
<span className="font-medium">{o.descripcion}</span>
<span className="text-muted-foreground"> {o.descripcionVencimiento}</span>
</li>
))}
</ul>
</div>
)}
</div>
);
}
// ============================================================================
// Declaraciones
// ============================================================================
function DeclaracionesTab() {
const user = useAuthStore((s) => s.user);
const canUpload = !!user?.role && ROLES_UPLOAD.includes(user.role);
const { selectedContribuyenteId } = useContribuyenteStore();
// Default: current month range
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth() + 1;
const lastDay = new Date(y, m, 0).getDate();
const [fechaDesde, setFechaDesde] = useState(`${y}-${String(m).padStart(2, '0')}-01`);
const [fechaHasta, setFechaHasta] = useState(`${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`);
const [uploadOpen, setUploadOpen] = useState(false);
const [pagoDeclaracion, setPagoDeclaracion] = useState<Declaracion | null>(null);
const { data: declaraciones, isLoading, error } = useDeclaraciones(fechaDesde, fechaHasta, selectedContribuyenteId);
const deleteDecl = useDeleteDeclaracion();
const downloadPdf = useDownloadDeclaracionPdf();
return (
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4 gap-2 flex-wrap">
<div>
<h2 className="text-lg font-semibold flex items-center gap-2"><FileText className="h-5 w-5" /> Declaraciones</h2>
<p className="text-sm text-muted-foreground mt-0.5">Sube el PDF de cada declaración y su comprobante de pago. Al subirla, se desactivan los recordatorios correspondientes.</p>
</div>
{canUpload && <Button size="sm" onClick={() => setUploadOpen(true)}><Plus className="h-4 w-4 mr-1.5" /> Subir declaración</Button>}
</div>
<div className="flex flex-wrap items-end gap-3 mb-4 pb-3 border-b">
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">Desde</label>
<Input type="date" value={fechaDesde} onChange={(e) => setFechaDesde(e.target.value)} className="h-8 w-[150px] text-sm" />
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">Hasta</label>
<Input type="date" value={fechaHasta} onChange={(e) => setFechaHasta(e.target.value)} className="h-8 w-[150px] text-sm" />
</div>
</div>
{isLoading && <div className="flex items-center justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>}
{error && <div className="p-4 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm">Error: {(error as Error).message}</div>}
{!isLoading && !error && declaraciones && declaraciones.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
<FileText className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p>No hay declaraciones en el rango seleccionado.</p>
{canUpload && <p className="text-sm mt-1">Usa el botón "Subir declaración" para cargar la primera.</p>}
</div>
)}
{!isLoading && declaraciones && declaraciones.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-2 font-medium">Periodo</th>
<th className="pb-2 pr-2 font-medium">Tipo</th>
<th className="pb-2 pr-2 font-medium">Impuestos</th>
<th className="pb-2 pr-2 font-medium text-right">Monto</th>
<th className="pb-2 pr-2 font-medium">Declaración</th>
<th className="pb-2 pr-2 font-medium">Pago</th>
<th className="pb-2 pr-2 font-medium">Fecha subida</th>
<th className="pb-2 pr-2 font-medium text-right">Acciones</th>
</tr>
</thead>
<tbody className="divide-y">
{declaraciones.map((d) => {
const isPaidByAmount = d.montoPago === 0;
const isPaid = d.tienePagoPdf || isPaidByAmount;
return (
<tr key={d.id} className="hover:bg-muted/50">
<td className="py-3 pr-2 font-medium">{getPeriodLabel(d.periodicidad, d.mes)} {d.año}</td>
<td className="py-3 pr-2">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${d.tipo === 'normal' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400' : 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400'}`}>
{d.tipo === 'normal' ? 'Normal' : 'Complementaria'}
</span>
</td>
<td className="py-3 pr-2">
<div className="flex flex-wrap gap-1">
{d.impuestos.map(i => <span key={i} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-muted">{i}</span>)}
</div>
</td>
<td className="py-3 pr-2 text-right font-mono text-sm">
{d.montoPago != null ? `$${d.montoPago.toLocaleString('es-MX', { minimumFractionDigits: 2 })}` : '—'}
</td>
<td className="py-3 pr-2">
<div className="flex flex-col gap-1">
{d.pdfFilename && (
<button onClick={() => downloadPdf.mutate({ id: d.id, variant: 'declaracion', filename: d.pdfFilename! })} className="inline-flex items-center gap-1 text-xs text-primary hover:underline">
<Download className="h-3 w-3" /> Declaración
</button>
)}
{d.tieneLigaPago && (
<button onClick={() => downloadPdf.mutate({ id: d.id, variant: 'liga', filename: d.ligaPagoFilename || `liga-pago-${d.id}.pdf` })} className="inline-flex items-center gap-1 text-xs text-primary hover:underline">
<Download className="h-3 w-3" /> Liga de pago
</button>
)}
</div>
</td>
<td className="py-3 pr-2">
{d.tienePagoPdf ? (
<button onClick={() => downloadPdf.mutate({ id: d.id, variant: 'pago', filename: d.pdfPagoFilename || `pago-${d.id}.pdf` })} className="inline-flex items-center gap-1 text-xs text-green-700 dark:text-green-400 hover:underline">
<CheckCircle2 className="h-3 w-3" /> Pagado
</button>
) : isPaidByAmount ? (
<span className="inline-flex items-center gap-1 text-xs text-green-700 dark:text-green-400">
<CheckCircle2 className="h-3 w-3" /> $0 Sin pago
</span>
) : (
<span className="text-xs text-muted-foreground">Sin comprobante</span>
)}
</td>
<td className="py-3 pr-2 text-xs text-muted-foreground">
{new Date(d.createdAt).toLocaleDateString('es-MX', { day: '2-digit', month: 'short', year: 'numeric' })}
</td>
<td className="py-3 pr-2">
<div className="flex items-center justify-end gap-1">
{canUpload && !isPaid && (
<Button variant="ghost" size="sm" onClick={() => setPagoDeclaracion(d)} title="Subir comprobante de pago">
<Receipt className="h-3.5 w-3.5" />
</Button>
)}
{canUpload && (
<Button variant="ghost" size="sm" onClick={() => { if (confirm('¿Eliminar esta declaración?')) deleteDecl.mutate(d.id); }} title="Eliminar">
<Trash2 className="h-3.5 w-3.5 text-red-600" />
</Button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
{uploadOpen && <UploadDialog onClose={() => setUploadOpen(false)} />}
{pagoDeclaracion && <ComprobantePagoDialog declaracion={pagoDeclaracion} onClose={() => setPagoDeclaracion(null)} />}
</Card>
);
}
// ============================================================================
// Upload Dialog
// ============================================================================
function UploadDialog({ onClose }: { onClose: () => void }) {
const create = useCreateDeclaracion();
const { selectedContribuyenteId } = useContribuyenteStore();
const currentYear = new Date().getFullYear();
const [año, setAño] = useState(currentYear);
const [mes, setMes] = useState(new Date().getMonth() + 1);
const [tipo, setTipo] = useState<'normal' | 'complementaria'>('normal');
const [periodicidad, setPeriodicidad] = useState<Periodicidad>('mensual');
const yearsOptions = Array.from({ length: 6 }, (_, i) => currentYear - i);
const [impuestos, setImpuestos] = useState<Impuesto[]>([]);
const [montoPago, setMontoPago] = useState('');
const [file, setFile] = useState<File | null>(null);
const [ligaFile, setLigaFile] = useState<File | null>(null);
const [notas, setNotas] = useState('');
const [err, setErr] = useState<string | null>(null);
const periodOptions = getPeriodOptions(periodicidad);
const handlePeriodicidadChange = (p: Periodicidad) => {
setPeriodicidad(p);
// Reset mes to first valid option for the new periodicidad
const opts = getPeriodOptions(p);
if (!opts.find(o => o.value === mes)) {
setMes(opts[0].value);
}
};
const toggleImpuesto = (i: Impuesto) => {
setImpuestos(prev => prev.includes(i) ? prev.filter(x => x !== i) : [...prev, i]);
};
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setErr(null);
if (!file) return setErr('Selecciona el PDF de la declaración');
if (impuestos.length === 0) return setErr('Selecciona al menos un impuesto');
try {
const pdfBase64 = await fileToBase64(file);
const ligaPagoBase64 = ligaFile ? await fileToBase64(ligaFile) : undefined;
const montoNum = montoPago.trim() !== '' ? parseFloat(montoPago) : undefined;
await create.mutateAsync({
año, mes, tipo, periodicidad, impuestos,
montoPago: montoNum,
pdfBase64, pdfFilename: file.name,
ligaPagoBase64,
ligaPagoFilename: ligaFile?.name,
notas: notas.trim() || undefined,
contribuyenteId: selectedContribuyenteId || undefined,
});
onClose();
} catch (e: any) {
setErr(e?.response?.data?.message || e?.message || 'Error al subir');
}
};
return (
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Subir declaración</DialogTitle>
<DialogDescription>
Al subir se marcarán como resueltos los recordatorios de declaración correspondientes. Si es complementaria, también los de pago.
</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="space-y-4">
<div className={`grid gap-3 ${periodicidad === 'anual' ? 'grid-cols-1' : 'grid-cols-2'}`}>
<div>
<Label>Periodicidad</Label>
<select value={periodicidad} onChange={(e) => handlePeriodicidadChange(e.target.value as Periodicidad)} className="w-full h-9 px-3 rounded-md border bg-background text-sm mt-1">
{PERIODICIDADES.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
</select>
</div>
{periodicidad !== 'anual' && (
<div>
<Label>Periodo</Label>
<select value={mes} onChange={(e) => setMes(Number(e.target.value))} className="w-full h-9 px-3 rounded-md border bg-background text-sm mt-1">
{periodOptions.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
)}
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<Label>Año</Label>
<select value={año} onChange={(e) => setAño(Number(e.target.value))} className="w-full h-9 px-3 rounded-md border bg-background text-sm mt-1">
{yearsOptions.map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
<div>
<Label>Tipo</Label>
<select value={tipo} onChange={(e) => setTipo(e.target.value as 'normal' | 'complementaria')} className="w-full h-9 px-3 rounded-md border bg-background text-sm mt-1">
<option value="normal">Normal</option>
<option value="complementaria">Complementaria</option>
</select>
</div>
<div>
<Label>Monto a pagar</Label>
<Input
type="number"
min="0"
step="0.01"
value={montoPago}
onChange={(e) => setMontoPago(e.target.value)}
placeholder="0.00"
className="mt-1"
/>
<p className="text-xs text-muted-foreground mt-1">Si es $0.00, se marca como pagado automáticamente.</p>
</div>
</div>
<div>
<Label>Impuestos cubiertos</Label>
<div className="grid grid-cols-3 gap-2 mt-1">
{IMPUESTOS.map(i => (
<label key={i} className={`flex items-center gap-2 px-3 py-2 rounded-md border cursor-pointer text-sm ${impuestos.includes(i) ? 'bg-primary/10 border-primary' : 'hover:bg-muted'}`}>
<input type="checkbox" checked={impuestos.includes(i)} onChange={() => toggleImpuesto(i)} className="accent-primary" />
{i}
</label>
))}
</div>
<p className="text-xs text-muted-foreground mt-1">Selecciona todos los impuestos que incluye esta declaración definen qué recordatorios se desactivan.</p>
</div>
<div>
<Label>PDF de la declaración</Label>
<Input type="file" accept="application/pdf" onChange={(e) => setFile(e.target.files?.[0] || null)} className="mt-1" />
</div>
<div>
<Label>PDF de la liga de pago (opcional)</Label>
<Input type="file" accept="application/pdf" onChange={(e) => setLigaFile(e.target.files?.[0] || null)} className="mt-1" />
<p className="text-xs text-muted-foreground mt-1">Documento con la línea de captura/referencia para pagar la declaración.</p>
</div>
<div>
<Label>Notas (opcional)</Label>
<textarea value={notas} onChange={(e) => setNotas(e.target.value)} rows={2} maxLength={2000} className="w-full px-3 py-2 rounded-md border bg-background text-sm mt-1" />
</div>
{err && <div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm">{err}</div>}
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Cancelar</Button>
<Button type="submit" disabled={create.isPending}>
{create.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" /> Subiendo...</> : <><Upload className="h-4 w-4 mr-1.5" /> Subir</>}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
// ============================================================================
// Comprobante de Pago Dialog
// ============================================================================
function ComprobantePagoDialog({ declaracion, onClose }: { declaracion: Declaracion; onClose: () => void }) {
const upload = useUploadComprobantePago();
const [file, setFile] = useState<File | null>(null);
const [err, setErr] = useState<string | null>(null);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setErr(null);
if (!file) return setErr('Selecciona el PDF del comprobante de pago');
try {
const pdfBase64 = await fileToBase64(file);
await upload.mutateAsync({ id: declaracion.id, pdfBase64, pdfFilename: file.name });
onClose();
} catch (e: any) {
setErr(e?.response?.data?.message || e?.message || 'Error al subir');
}
};
return (
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Subir comprobante de pago</DialogTitle>
<DialogDescription>
{MESES[declaracion.mes - 1]} {declaracion.año} {declaracion.tipo === 'normal' ? 'Normal' : 'Complementaria'}. Al subirlo se marcan como pagados los recordatorios de pago correspondientes.
</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="space-y-4">
<div>
<Label>PDF del comprobante</Label>
<Input type="file" accept="application/pdf" onChange={(e) => setFile(e.target.files?.[0] || null)} className="mt-1" />
</div>
{err && <div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm">{err}</div>}
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Cancelar</Button>
<Button type="submit" disabled={upload.isPending}>
{upload.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" /> Subiendo...</> : <><Upload className="h-4 w-4 mr-1.5" /> Subir</>}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
// ============================================================================
// Extras — PDFs libres (acuses, contratos, poderes, estados de cuenta, etc.)
// ============================================================================
const ROLES_UPLOAD_EXTRA = ['owner', 'cfo', 'contador', 'auxiliar'];
function ExtrasTab() {
const user = useAuthStore((s) => s.user);
const canUpload = !!user?.role && ROLES_UPLOAD_EXTRA.includes(user.role);
const { selectedContribuyenteId } = useContribuyenteStore();
const qc = useQueryClient();
const [uploadOpen, setUploadOpen] = useState(false);
const [categoriaFiltro, setCategoriaFiltro] = useState<string>('');
const extrasQ = useQuery({
queryKey: ['documentos-extras', selectedContribuyenteId, categoriaFiltro],
queryFn: () => docsApi.listarExtras(selectedContribuyenteId, categoriaFiltro || null),
});
const categoriasQ = useQuery({
queryKey: ['documentos-extras-categorias', selectedContribuyenteId],
queryFn: () => docsApi.listarCategoriasExtras(selectedContribuyenteId),
});
const deleteMut = useMutation({
mutationFn: (id: number) => docsApi.eliminarExtra(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['documentos-extras'] });
qc.invalidateQueries({ queryKey: ['documentos-extras-categorias'] });
},
});
const handleDelete = (id: number, nombre: string) => {
if (!confirm(`¿Eliminar "${nombre}"? Esta accion no se puede deshacer.`)) return;
deleteMut.mutate(id);
};
const handleDownload = async (id: number, filename: string) => {
try {
const blob = await docsApi.descargarExtraPdf(id);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch {
alert('Error al descargar el documento');
}
};
const extras = extrasQ.data || [];
const categorias = categoriasQ.data || [];
return (
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between gap-2 mb-4 flex-wrap">
<div className="flex items-center gap-3 flex-wrap">
<h3 className="text-lg font-semibold">Documentos extras</h3>
{categorias.length > 0 && (
<div className="flex items-center gap-1.5 text-sm">
<Tag className="h-4 w-4 text-muted-foreground" />
<select
value={categoriaFiltro}
onChange={(e) => setCategoriaFiltro(e.target.value)}
className="h-8 rounded-md border border-input bg-background px-2 text-sm"
>
<option value="">Todas las categorias</option>
{categorias.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
)}
</div>
{canUpload && (
<Button onClick={() => setUploadOpen(true)} size="sm">
<Plus className="h-4 w-4 mr-1.5" />
Subir PDF
</Button>
)}
</div>
{extrasQ.isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : extras.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{categoriaFiltro
? `No hay documentos en la categoria "${categoriaFiltro}"`
: 'Aun no hay documentos extras. Sube el primero con el boton de arriba.'}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-3 font-medium">Nombre</th>
<th className="pb-3 font-medium">Categoría</th>
<th className="pb-3 font-medium">Descripción</th>
<th className="pb-3 font-medium">Subido por</th>
<th className="pb-3 font-medium">Fecha</th>
<th className="pb-3 font-medium text-right">Acciones</th>
</tr>
</thead>
<tbody>
{extras.map((e) => (
<tr key={e.id} className="border-b hover:bg-muted/50">
<td className="py-3 font-medium">{e.nombre}</td>
<td className="py-3">
{e.categoria ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-muted text-muted-foreground">
<Tag className="h-3 w-3" />
{e.categoria}
</span>
) : (
<span className="text-muted-foreground text-xs"></span>
)}
</td>
<td className="py-3 max-w-[300px] truncate text-muted-foreground" title={e.descripcion || ''}>
{e.descripcion || '—'}
</td>
<td className="py-3 text-xs text-muted-foreground">{e.subidoPor || '—'}</td>
<td className="py-3 text-xs text-muted-foreground">
{new Date(e.createdAt).toLocaleDateString('es-MX')}
</td>
<td className="py-3">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleDownload(e.id, e.pdfFilename)}
title="Descargar PDF"
>
<Download className="h-4 w-4" />
</Button>
{canUpload && (
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(e.id, e.nombre)}
disabled={deleteMut.isPending}
title="Eliminar"
className="hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
<p className="text-xs text-muted-foreground mt-4">
{extras.length} documento{extras.length !== 1 ? 's' : ''}
</p>
</div>
)}
</CardContent>
{uploadOpen && (
<UploadExtraDialog
open={uploadOpen}
onClose={() => setUploadOpen(false)}
contribuyenteId={selectedContribuyenteId}
categoriasExistentes={categorias}
/>
)}
</Card>
);
}
function UploadExtraDialog({
open, onClose, contribuyenteId, categoriasExistentes,
}: {
open: boolean;
onClose: () => void;
contribuyenteId: string | null;
categoriasExistentes: string[];
}) {
const qc = useQueryClient();
const [nombre, setNombre] = useState('');
const [descripcion, setDescripcion] = useState('');
const [categoria, setCategoria] = useState('');
const [file, setFile] = useState<File | null>(null);
const [err, setErr] = useState<string | null>(null);
const upload = useMutation({
mutationFn: async () => {
if (!file) throw new Error('Selecciona un archivo PDF');
if (!nombre.trim()) throw new Error('El nombre es requerido');
const pdfBase64 = await fileToBase64(file);
return docsApi.crearExtra(
{
nombre: nombre.trim(),
descripcion: descripcion.trim() || undefined,
categoria: categoria.trim() || undefined,
pdfBase64,
pdfFilename: file.name,
},
contribuyenteId,
);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['documentos-extras'] });
qc.invalidateQueries({ queryKey: ['documentos-extras-categorias'] });
onClose();
},
onError: (e: any) => {
setErr(e?.response?.data?.message || e?.message || 'Error al subir');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setErr(null);
upload.mutate();
};
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Subir documento extra</DialogTitle>
<DialogDescription>
PDFs libres como acuses del SAT, contratos, poderes notariales, estados de cuenta, etc.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 pt-2">
<div>
<Label>Nombre *</Label>
<Input
value={nombre}
onChange={(e) => setNombre(e.target.value)}
placeholder="Ej. Acuse declaracion anual 2024"
className="mt-1"
maxLength={255}
/>
</div>
<div>
<Label>Categoría (opcional)</Label>
<Input
value={categoria}
onChange={(e) => setCategoria(e.target.value)}
placeholder="Ej. Acuses SAT, Contratos, Poderes..."
className="mt-1"
list="categorias-extras-list"
maxLength={100}
/>
{categoriasExistentes.length > 0 && (
<datalist id="categorias-extras-list">
{categoriasExistentes.map((c) => <option key={c} value={c} />)}
</datalist>
)}
</div>
<div>
<Label>Descripción (opcional)</Label>
<textarea
value={descripcion}
onChange={(e) => setDescripcion(e.target.value)}
placeholder="Notas internas sobre el documento"
rows={3}
maxLength={2000}
className="mt-1 flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div>
<Label>PDF *</Label>
<Input
type="file"
accept="application/pdf"
onChange={(e) => setFile(e.target.files?.[0] || null)}
className="mt-1"
/>
</div>
{err && (
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm">
{err}
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Cancelar</Button>
<Button type="submit" disabled={upload.isPending}>
{upload.isPending ? (
<><Loader2 className="h-4 w-4 animate-spin mr-2" /> Subiendo...</>
) : (
<><Upload className="h-4 w-4 mr-1.5" /> Subir</>
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,179 @@
'use client';
import { useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, Button, SortableHeader, cn } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { formatCurrency } from '@/lib/utils';
import { exportToExcel } from '@/lib/export-excel';
import { useTableSort } from '@horux/shared-ui';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
import { Eye, Download } from 'lucide-react';
import type { Cfdi } from '@horux/shared';
const EXCEL_COLUMNS = [
{ header: 'UUID', key: 'uuid', width: 40 },
{ header: 'Comprobante', key: 'tipoComprobante', width: 12 },
{ header: 'Fecha Emision', key: '_fecha', width: 15 },
{ header: 'RFC Emisor', key: 'rfcEmisor', width: 15 },
{ header: 'Nombre Emisor', key: 'nombreEmisor', width: 30 },
{ header: 'RFC Receptor', key: 'rfcReceptor', width: 15 },
{ header: 'Nombre Receptor', key: 'nombreReceptor', width: 30 },
{ header: 'Total MXN', key: '_totalMxn', width: 15 },
{ header: 'Monto Pago MXN', key: '_montoPagoMxn', width: 15 },
{ header: 'IVA Trasladado MXN', key: '_ivaMxn', width: 18 },
{ header: 'Metodo Pago', key: 'metodoPago', width: 12 },
{ header: 'Regimen Emisor', key: 'regimenEmisor', width: 15 },
{ header: 'Regimen Receptor', key: 'regimenReceptor', width: 15 },
];
function prepareRows(data: any[]) {
return data.map((c) => ({
...c,
_fecha: new Date(c.fechaEmision).toLocaleDateString('es-MX'),
_totalMxn: Number(c.totalMxn || 0),
_montoPagoMxn: Number(c.montoPagoMxn || 0),
_ivaMxn: Number(c.ivaTrasladoMxn || 0),
}));
}
export default function DrillDownPage() {
const searchParams = useSearchParams();
const titulo = searchParams.get('titulo') || 'Detalle de CFDIs';
const [selectedCfdi, setSelectedCfdi] = useState<Cfdi | null>(null);
const { selectedContribuyenteId } = useContribuyenteStore();
const params = new URLSearchParams();
for (const [key, value] of searchParams.entries()) {
if (key !== 'titulo') params.set(key, value);
}
// Respetar contribuyente seleccionado globalmente — así cualquier drillUrl
// construido desde dashboard/impuestos/etc queda automáticamente filtrado
// sin tener que acordarse de pasarlo en cada call-site. El URLSearchParams
// de entrada gana si el caller sí lo pasó explícitamente.
if (selectedContribuyenteId && !params.has('contribuyenteId')) {
params.set('contribuyenteId', selectedContribuyenteId);
}
const { data, isLoading } = useQuery({
queryKey: ['drill-down', params.toString()],
queryFn: async () => {
const res = await apiClient.get<Cfdi[]>(`/cfdi/drill-down?${params}`);
return res.data;
},
});
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'total' | 'pago' | 'iva'>(
data,
{
fecha: (c) => new Date(c.fechaEmision).getTime(),
total: (c) => Number(c.totalMxn || 0),
pago: (c) => Number(c.montoPagoMxn || 0),
iva: (c) => Number(c.ivaTrasladoMxn || 0),
},
'fecha',
);
// Total con signo: tipo E resta (es una nota de crédito que reduce el bucket).
// Tipo I/N suman total_mxn; tipo P suma monto_pago_mxn (su total es 0 por convención
// del complemento). Así el total del header coincide con los KPIs del dashboard.
const totalMxn = data?.reduce((s, r) => {
const sign = r.tipoComprobante === 'E' ? -1 : 1;
const amount = r.tipoComprobante === 'P'
? Number(r.montoPagoMxn || 0)
: Number(r.totalMxn || 0);
return s + sign * amount;
}, 0) || 0;
const totalPagos = data?.reduce((s, r) => s + Number(r.montoPagoMxn || 0), 0) || 0;
const handleExport = () => {
if (!sortedData || sortedData.length === 0) return;
exportToExcel(prepareRows(sortedData), EXCEL_COLUMNS, 'drill-down-cfdis');
};
return (
<DashboardShell title={titulo}>
<Card>
<CardContent className="pt-6">
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : !data || data.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">No hay CFDIs que coincidan con los filtros</div>
) : (
<>
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-muted-foreground">{data.length} CFDIs encontrados</p>
<div className="flex items-center gap-4 text-sm">
<span>Total MXN: <strong>{formatCurrency(totalMxn)}</strong></span>
{totalPagos > 0 && <span>Pagos MXN: <strong>{formatCurrency(totalPagos)}</strong></span>}
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="h-4 w-4 mr-1" />
Excel
</Button>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-3 font-medium">UUID</th>
<th className="pb-3 font-medium">Comp.</th>
<SortableHeader label="Fecha" active={getSortIndicator('fecha')} onClick={() => toggleSort('fecha')} />
<th className="pb-3 font-medium">RFC Emisor</th>
<th className="pb-3 font-medium">Nombre Emisor</th>
<th className="pb-3 font-medium">RFC Receptor</th>
<th className="pb-3 font-medium">Nombre Receptor</th>
<SortableHeader label="Total MXN" align="right" active={getSortIndicator('total')} onClick={() => toggleSort('total')} />
<SortableHeader label="Monto Pago" align="right" active={getSortIndicator('pago')} onClick={() => toggleSort('pago')} />
<SortableHeader label="IVA Trasl." align="right" active={getSortIndicator('iva')} onClick={() => toggleSort('iva')} />
<th className="pb-3 font-medium">M. Pago</th>
<th className="pb-3 font-medium">Reg. E</th>
<th className="pb-3 font-medium">Reg. R</th>
<th className="pb-3 font-medium"></th>
</tr>
</thead>
<tbody>
{(sortedData || []).map((cfdi: any) => {
const isNC = cfdi.tipoComprobante === 'E';
return (
<tr key={cfdi.id} className={cn('border-b hover:bg-muted/50', isNC && 'bg-red-50/50 dark:bg-red-950/20')}>
<td className="py-2 font-mono text-xs" title={cfdi.uuid}>{cfdi.uuid?.substring(0, 8)}</td>
<td className={cn('py-2 text-xs font-mono', isNC && 'text-red-600 dark:text-red-400 font-semibold')} title={isNC ? 'Nota de crédito — resta del total' : undefined}>{cfdi.tipoComprobante}</td>
<td className="py-2 text-xs">{new Date(cfdi.fechaEmision).toLocaleDateString('es-MX')}</td>
<td className="py-2 font-mono text-xs">{cfdi.rfcEmisor}</td>
<td className="py-2 text-xs truncate max-w-[120px]" title={cfdi.nombreEmisor}>{cfdi.nombreEmisor}</td>
<td className="py-2 font-mono text-xs">{cfdi.rfcReceptor}</td>
<td className="py-2 text-xs truncate max-w-[120px]" title={cfdi.nombreReceptor}>{cfdi.nombreReceptor}</td>
<td className={cn('py-2 text-right text-xs font-medium', isNC && 'text-red-600 dark:text-red-400')}>{isNC ? '' : ''}{formatCurrency(Number(cfdi.totalMxn))}</td>
<td className="py-2 text-right text-xs">{cfdi.tipoComprobante === 'P' && cfdi.montoPagoMxn ? formatCurrency(Number(cfdi.montoPagoMxn)) : '-'}</td>
<td className="py-2 text-right text-xs">{cfdi.ivaTrasladoMxn ? formatCurrency(Number(cfdi.ivaTrasladoMxn)) : '-'}</td>
<td className="py-2 text-xs">{cfdi.metodoPago || '-'}</td>
<td className="py-2 text-xs font-mono">{cfdi.regimenEmisor || '-'}</td>
<td className="py-2 text-xs font-mono">{cfdi.regimenReceptor || '-'}</td>
<td className="py-2">
<Button variant="ghost" size="sm" onClick={() => setSelectedCfdi(cfdi)} title="Ver factura">
<Eye className="h-4 w-4" />
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
)}
</CardContent>
</Card>
<CfdiViewerModal
cfdi={selectedCfdi}
open={!!selectedCfdi}
onClose={() => setSelectedCfdi(null)}
/>
</DashboardShell>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,219 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, CardDescription, Button } from '@horux/shared-ui';
import { useTimbres } from '@/lib/hooks/use-facturacion';
import { getPaquetesCatalogo, comprarPaquete } from '@/lib/api/facturacion';
import { formatCurrency } from '@/lib/utils';
import { Receipt, Zap, Package, ArrowLeft, ShoppingCart, Loader2, CheckCircle2, AlertTriangle, Calendar } from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
export default function TimbresPage() {
const router = useRouter();
const { user } = useAuthStore();
const { data: timbres, isLoading } = useTimbres();
const { data: catalogo = [] } = useQuery({
queryKey: ['timbres-paquetes-catalogo'],
queryFn: getPaquetesCatalogo,
});
const [buying, setBuying] = useState<number | null>(null);
const canBuy = user?.role === 'owner' || user?.role === 'cfo';
const handleComprar = async (catalogoId: number) => {
if (!canBuy) return;
setBuying(catalogoId);
try {
const { checkoutUrl } = await comprarPaquete(catalogoId);
window.location.href = checkoutUrl;
} catch (err: any) {
alert(err?.response?.data?.message || 'Error al iniciar compra');
setBuying(null);
}
};
const mensualDisp = timbres?.mensual?.disponibles ?? (timbres?.disponibles ?? 0);
const mensualTotal = timbres?.mensual?.limite ?? (timbres?.limite ?? 0);
const adicionales = timbres?.adicionales;
return (
<>
<Header title="Timbres adicionales">
<button onClick={() => router.push('/facturacion')} className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="h-4 w-4" /> Volver a facturación
</button>
</Header>
<main className="p-6 space-y-6">
{/* Status actual */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Zap className="h-4 w-4 text-amber-500" /> Plan mensual
</CardTitle>
<CardDescription className="text-xs">Se resetea cada mes. No acumulable.</CardDescription>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{mensualDisp}</div>
<p className="text-xs text-muted-foreground">de {mensualTotal} disponibles</p>
{timbres?.mensual?.periodoFin && (
<p className="text-xs text-muted-foreground mt-1">
Renueva: {new Date(timbres.mensual.periodoFin).toLocaleDateString('es-MX')}
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Package className="h-4 w-4 text-blue-500" /> Adicionales
</CardTitle>
<CardDescription className="text-xs">Comprados. Vigencia 1 año c/u.</CardDescription>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{adicionales?.disponibles ?? 0}</div>
<p className="text-xs text-muted-foreground">
de {adicionales?.total ?? 0} ({adicionales?.usados ?? 0} usados)
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Receipt className="h-4 w-4 text-green-600" /> Total disponible
</CardTitle>
<CardDescription className="text-xs">Suma mensual + adicionales.</CardDescription>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{timbres?.totalDisponibles ?? mensualDisp}</div>
<p className="text-xs text-muted-foreground">timbres listos para emitir</p>
</CardContent>
</Card>
</div>
{/* Explicación de orden de consumo */}
<Card className="bg-muted/30 border-dashed">
<CardContent className="py-4 text-sm flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 mt-0.5 flex-shrink-0" />
<div>
<strong>Orden de consumo:</strong> cada factura emitida descuenta primero
de tus timbres mensuales del plan. Solo cuando estos se agoten empieza a
consumir de tus paquetes adicionales, comenzando por los más próximos a
vencer para no desperdiciarlos.
</div>
</CardContent>
</Card>
{/* Catálogo de paquetes */}
<div>
<h2 className="text-lg font-semibold mb-3">Comprar paquetes</h2>
{!canBuy && (
<div className="text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded px-3 py-2 mb-3">
Solo el dueño o CFO de la empresa pueden comprar paquetes adicionales.
</div>
)}
<div className="grid gap-4 md:grid-cols-3">
{catalogo.map(p => (
<Card key={p.id} className="flex flex-col">
<CardHeader>
<CardTitle className="text-2xl">
{p.cantidad.toLocaleString('es-MX')}
<span className="text-sm font-normal text-muted-foreground ml-1">timbres</span>
</CardTitle>
<CardDescription>Vigencia 1 año desde la compra</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col justify-between">
<div>
<div className="text-3xl font-bold">{formatCurrency(p.precio)}</div>
<p className="text-xs text-muted-foreground">
{formatCurrency(p.precio / p.cantidad)} por timbre · IVA incluido
</p>
</div>
<Button
className="mt-4 w-full"
onClick={() => handleComprar(p.id)}
disabled={!canBuy || buying !== null}
>
{buying === p.id ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Redirigiendo...</>
) : (
<><ShoppingCart className="h-4 w-4 mr-2" /> Comprar</>
)}
</Button>
</CardContent>
</Card>
))}
</div>
<p className="text-xs text-muted-foreground mt-3">
Al completar el pago en MercadoPago, tu factura se emitirá automáticamente.
</p>
</div>
{/* Paquetes vigentes */}
{adicionales && adicionales.paquetes.length > 0 && (
<div>
<h2 className="text-lg font-semibold mb-3">Paquetes vigentes</h2>
<Card>
<CardContent className="pt-6">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 font-medium">Cantidad</th>
<th className="pb-2 font-medium">Usados</th>
<th className="pb-2 font-medium">Disponibles</th>
<th className="pb-2 font-medium">Adquirido</th>
<th className="pb-2 font-medium">Expira</th>
</tr>
</thead>
<tbody>
{adicionales.paquetes.map(p => {
const expira = new Date(p.expiraEn);
const diasRestantes = Math.floor((expira.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
return (
<tr key={p.id} className="border-b last:border-0">
<td className="py-2">{p.cantidad.toLocaleString('es-MX')}</td>
<td className="py-2">{p.usados.toLocaleString('es-MX')}</td>
<td className="py-2 font-medium">{p.disponibles.toLocaleString('es-MX')}</td>
<td className="py-2 text-xs text-muted-foreground">
{new Date(p.adquiridoEn).toLocaleDateString('es-MX')}
</td>
<td className="py-2 text-xs">
<span className={diasRestantes < 30 ? 'text-amber-600 font-medium' : 'text-muted-foreground'}>
<Calendar className="h-3 w-3 inline mr-1" />
{expira.toLocaleDateString('es-MX')} ({diasRestantes} días)
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
)}
{/* Success banner post-MP */}
{typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('timbres') === 'success' && (
<Card className="border-green-200 bg-green-50">
<CardContent className="py-4 flex items-start gap-2 text-sm text-green-800">
<CheckCircle2 className="h-5 w-5 flex-shrink-0" />
<div>
<strong>Pago recibido.</strong> Tu paquete se activará en cuanto MercadoPago confirme la transacción (~1-2 minutos). Recarga la página si no lo ves enseguida.
</div>
</CardContent>
</Card>
)}
</main>
</>
);
}

View File

@@ -0,0 +1,643 @@
'use client';
import { useState } from 'react';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, Button, Input } from '@horux/shared-ui';
import { KpiCard, PeriodSelector, RegimenSelector } from '@horux/shared-ui';
import { useIvaMensual, useIsrMensual, useResumenIva, useResumenIsr, useResumenIsrDesglosado, useCoeficiente } from '@/lib/hooks/use-impuestos';
import { setCoeficiente as setCoeficienteApi } from '@/lib/api/impuestos';
import { useQueryClient } from '@tanstack/react-query';
import { useRegimenesDelPeriodo } from '@/lib/hooks/use-dashboard';
import { Calculator, TrendingUp, TrendingDown, Receipt, Settings, Wallet, CheckSquare, Download } from 'lucide-react';
import { cn } from '@horux/shared-ui';
import { FiscalDisclaimer } from '@/components/fiscal-disclaimer';
import { exportToExcel } from '@/lib/export-excel';
import { ActivosFijosTab } from '@/components/impuestos/activos-fijos-tab';
const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
function getMonthRange(year: number, month: number) {
const start = `${year}-${String(month).padStart(2, '0')}-01`;
const lastDay = new Date(year, month, 0).getDate();
const end = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
return { start, end };
}
export default function ImpuestosPage() {
const now = new Date();
const defaultRange = getMonthRange(now.getFullYear(), now.getMonth() + 1);
const [fechaInicio, setFechaInicio] = useState(defaultRange.start);
const [fechaFin, setFechaFin] = useState(defaultRange.end);
const [activeTab, setActiveTab] = useState<'iva' | 'isr' | 'activos-fijos'>('iva');
const [regimenSeleccionado, setRegimenSeleccionado] = useState<string | null>(null);
const [conciliacion, setConciliacion] = useState(false);
const [considerarActivos, setConsiderarActivos] = useState(true);
const [considerarNCs, setConsiderarNCs] = useState(true);
const año = new Date(fechaInicio + 'T00:00:00').getFullYear();
const mes = new Date(fechaInicio + 'T00:00:00').getMonth() + 1;
const queryClient = useQueryClient();
const [coefInput, setCoefInput] = useState('');
const [savingCoef, setSavingCoef] = useState(false);
const { data: ivaMensual, isLoading: ivaLoading } = useIvaMensual(año, conciliacion, considerarActivos, considerarNCs);
const { data: isrMensual, isLoading: isrLoading } = useIsrMensual(año, conciliacion, regimenSeleccionado, considerarActivos, considerarNCs);
const { data: resumenIva } = useResumenIva(fechaInicio, fechaFin, conciliacion, considerarActivos, considerarNCs);
const { data: resumenIsr } = useResumenIsr(fechaInicio, fechaFin, conciliacion, considerarActivos, considerarNCs);
const { data: resumenIsrDesglose } = useResumenIsrDesglosado(fechaFin, conciliacion, considerarActivos, considerarNCs);
const { data: coefData } = useCoeficiente(año);
const { data: regimenesPeriodo, isLoading: regimenesLoading } = useRegimenesDelPeriodo(fechaInicio, fechaFin, conciliacion);
const regimenesDisponibles = regimenesPeriodo || [];
if (regimenSeleccionado && regimenesDisponibles.length > 0 &&
!regimenesDisponibles.find(r => r.clave === regimenSeleccionado)) {
setRegimenSeleccionado(null);
}
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
minimumFractionDigits: 0,
}).format(value);
const drillUrl = (titulo: string, filters: Record<string, string>) => {
const p = new URLSearchParams({ titulo, fechaInicio, fechaFin, status: 'vigente', ...filters });
if (regimenSeleccionado) {
if (filters.type === 'EMITIDO') p.set('regimenEmisor', regimenSeleccionado);
if (filters.type === 'RECIBIDO') p.set('regimenReceptor', regimenSeleccionado);
}
return `/drill-down?${p}`;
};
return (
<>
<Header title="Control de Impuestos">
<PeriodSelector
fechaInicio={fechaInicio}
fechaFin={fechaFin}
onChange={(fi, ff) => { setFechaInicio(fi); setFechaFin(ff); }}
/>
</Header>
<main className="p-6 space-y-6">
{/* Filtros */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<RegimenSelector
regimenes={regimenesDisponibles}
selected={regimenSeleccionado}
onChange={setRegimenSeleccionado}
isLoading={regimenesLoading}
/>
<button
onClick={() => setConciliacion(!conciliacion)}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
conciliacion
? 'bg-primary/10 text-primary border border-primary/30'
: 'hover:bg-accent'
)}
>
<CheckSquare className="h-4 w-4" />
Conciliación
</button>
<button
onClick={() => setConsiderarActivos(!considerarActivos)}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
considerarActivos
? 'bg-primary/10 text-primary border border-primary/30'
: 'hover:bg-accent'
)}
title="Si está inactivo, no se consideran facturas tipo I con uso de CFDI I01-I08 (compras de activos fijos)."
>
<CheckSquare className="h-4 w-4" />
Considerar activos
</button>
<button
onClick={() => setConsiderarNCs(!considerarNCs)}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
considerarNCs
? 'bg-primary/10 text-primary border border-primary/30'
: 'hover:bg-accent'
)}
title="Si está inactivo, no se consideran facturas tipo E con tipo de relación 01 (notas de crédito)."
>
<CheckSquare className="h-4 w-4" />
Considerar NCs
</button>
</div>
</div>
<div className="flex gap-2">
<Button
variant={activeTab === 'iva' ? 'default' : 'outline'}
onClick={() => setActiveTab('iva')}
>
<Receipt className="h-4 w-4 mr-2" />
IVA
</Button>
<Button
variant={activeTab === 'isr' ? 'default' : 'outline'}
onClick={() => setActiveTab('isr')}
>
<Calculator className="h-4 w-4 mr-2" />
ISR
</Button>
<Button
variant={activeTab === 'activos-fijos' ? 'default' : 'outline'}
onClick={() => setActiveTab('activos-fijos')}
>
<Wallet className="h-4 w-4 mr-2" />
Activos Fijos
</Button>
</div>
{activeTab === 'iva' && (
<>
{/* IVA KPIs */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
<KpiCard
title={regimenSeleccionado ? `IVA Trasladado (${regimenSeleccionado})` : 'IVA Trasladado'}
value={
regimenSeleccionado
? resumenIva?.trasladadoPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: resumenIva?.trasladado || 0
}
icon={<TrendingUp className="h-4 w-4" />}
subtitle="Cobrado a clientes"
href={drillUrl('IVA Trasladado - CFDIs Emitidos', { bucket: 'causado' })}
/>
<KpiCard
title={regimenSeleccionado ? `IVA Acreditable (${regimenSeleccionado})` : 'IVA Acreditable'}
value={
regimenSeleccionado
? resumenIva?.acreditablePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: resumenIva?.acreditable || 0
}
icon={<TrendingDown className="h-4 w-4" />}
subtitle="Pagado a proveedores"
href={drillUrl('IVA Acreditable - CFDIs Recibidos', { bucket: 'acreditable' })}
/>
{(() => {
const val = regimenSeleccionado
? resumenIva?.retenidoPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: resumenIva?.retenido || 0;
return (
<KpiCard
title={regimenSeleccionado ? `IVA Retenido (${regimenSeleccionado})` : 'IVA Retenido'}
value={val}
icon={<Receipt className="h-4 w-4" />}
trend={val > 0 ? 'up' : val < 0 ? 'down' : 'neutral'}
trendValue={val > 0 ? 'A favor' : val < 0 ? 'En contra' : 'Neutro'}
/>
);
})()}
{(() => {
const t = regimenSeleccionado
? resumenIva?.trasladadoPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: resumenIva?.trasladado || 0;
const a = regimenSeleccionado
? resumenIva?.acreditablePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: resumenIva?.acreditable || 0;
const ret = regimenSeleccionado
? resumenIva?.retenidoPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: resumenIva?.retenido || 0;
const res = t - a - ret;
return (
<KpiCard
title={regimenSeleccionado ? `Resultado (${regimenSeleccionado})` : 'Resultado del Periodo'}
value={res}
icon={<Calculator className="h-4 w-4" />}
trend={res > 0 ? 'up' : res < 0 ? 'down' : 'neutral'}
trendValue={res > 0 ? 'Por pagar' : res < 0 ? 'A favor' : 'Neutro'}
/>
);
})()}
<KpiCard
title="Acumulado Anual"
value={resumenIva?.acumuladoAnual || 0}
icon={<Receipt className="h-4 w-4" />}
trend={(resumenIva?.acumuladoAnual || 0) < 0 ? 'up' : 'neutral'}
trendValue={(resumenIva?.acumuladoAnual || 0) < 0 ? 'Saldo a favor' : ''}
/>
</div>
{/* IVA Mensual Table */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Histórico IVA {año}</CardTitle>
{ivaMensual && ivaMensual.length > 0 && (
<Button variant="outline" size="sm" onClick={() => exportToExcel(
ivaMensual.map(r => ({
Mes: meses[r.mes - 1],
Trasladado: r.ivaTrasladado,
Acreditable: r.ivaAcreditable,
Retenido: r.ivaRetenido,
Resultado: r.resultado,
Acumulado: r.acumulado,
})),
[
{ header: 'Mes', key: 'Mes', width: 12 },
{ header: 'Trasladado', key: 'Trasladado', width: 18 },
{ header: 'Acreditable', key: 'Acreditable', width: 18 },
{ header: 'Retenido', key: 'Retenido', width: 18 },
{ header: 'Resultado', key: 'Resultado', width: 18 },
{ header: 'Acumulado', key: 'Acumulado', width: 18 },
],
`iva-mensual-${año}`,
)}>
<Download className="h-4 w-4 mr-1" /> Excel
</Button>
)}
</CardHeader>
<CardContent>
{ivaLoading ? (
<div className="text-center py-8 text-muted-foreground">
Cargando...
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b text-left text-sm text-muted-foreground">
<th className="pb-3 font-medium">Mes</th>
<th className="pb-3 font-medium text-right">Trasladado</th>
<th className="pb-3 font-medium text-right">Acreditable</th>
<th className="pb-3 font-medium text-right">Retenido</th>
<th className="pb-3 font-medium text-right">Resultado</th>
<th className="pb-3 font-medium text-right">Acumulado</th>
<th className="pb-3 font-medium">Estado</th>
</tr>
</thead>
<tbody className="text-sm">
{ivaMensual?.map((row) => (
<tr key={row.mes} className="border-b hover:bg-muted/50">
<td className="py-3 font-medium">{meses[row.mes - 1]}</td>
<td className="py-3 text-right">
{formatCurrency(row.ivaTrasladado)}
</td>
<td className="py-3 text-right">
{formatCurrency(row.ivaAcreditable)}
</td>
<td className="py-3 text-right">
{formatCurrency(row.ivaRetenido)}
</td>
<td
className={`py-3 text-right font-medium ${
row.resultado > 0
? 'text-destructive'
: 'text-success'
}`}
>
{formatCurrency(row.resultado)}
</td>
<td
className={`py-3 text-right font-medium ${
row.acumulado > 0
? 'text-destructive'
: 'text-success'
}`}
>
{formatCurrency(row.acumulado)}
</td>
<td className="py-3">
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${
row.estado === 'declarado'
? 'bg-success/10 text-success'
: 'bg-warning/10 text-warning'
}`}
>
{row.estado === 'declarado' ? 'Declarado' : 'Pendiente'}
</span>
</td>
</tr>
))}
{(!ivaMensual || ivaMensual.length === 0) && (
<tr>
<td colSpan={7} className="py-8 text-center text-muted-foreground">
No hay registros de IVA para este año
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</>
)}
{activeTab === 'isr' && (
<>
{/* ISR KPIs */}
{(() => {
const bg = regimenSeleccionado
? resumenIsr?.baseGravablePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)
: null;
const showUtilidad = true;
const ingSel = regimenSeleccionado
? resumenIsr?.ingresosPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: resumenIsr?.ingresosAcumulados || 0;
const dedSel = regimenSeleccionado
? resumenIsr?.deduccionesPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: resumenIsr?.deducciones || 0;
// ISR a pagar filtered by regime
const bgSelForKpi = regimenSeleccionado
? resumenIsr?.baseGravablePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)
: null;
const isrCausadoSel = regimenSeleccionado
? (bgSelForKpi?.isrCausado || 0)
: resumenIsr?.isrCausado || 0;
const isrRetenidoSel = regimenSeleccionado ? 0 : resumenIsr?.isrRetenido || 0;
const isrAPagarSel = Math.max(0, isrCausadoSel - isrRetenidoSel);
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
<KpiCard
title={regimenSeleccionado ? `Ingresos ISR (${regimenSeleccionado})` : 'Ingresos Acumulados'}
value={ingSel}
icon={<TrendingUp className="h-4 w-4" />}
href={drillUrl('Ingresos ISR - CFDIs Emitidos', { bucket: 'ingresos' })}
/>
<KpiCard
title={regimenSeleccionado ? `Deducciones (${regimenSeleccionado})` : 'Deducciones'}
value={dedSel}
icon={<TrendingDown className="h-4 w-4" />}
href={drillUrl('Deducciones - CFDIs Recibidos', { bucket: 'gastos' })}
/>
<KpiCard
title={regimenSeleccionado ? `Base Gravable (${regimenSeleccionado})` : 'Base Gravable'}
value={regimenSeleccionado ? (bg?.baseGravable ?? 0) : resumenIsr?.baseGravable || 0}
icon={<Calculator className="h-4 w-4" />}
subtitle={bg ? (bg.formula === 'ingresos-deducciones' ? 'Ingresos - Deducciones' : 'Solo ingresos') : undefined}
/>
<KpiCard
title={regimenSeleccionado ? `ISR a Pagar (${regimenSeleccionado})` : 'ISR a Pagar'}
value={isrAPagarSel}
icon={<Receipt className="h-4 w-4" />}
trend={isrAPagarSel > 0 ? 'up' : 'neutral'}
/>
<KpiCard
title={regimenSeleccionado ? `Utilidad del Periodo (${regimenSeleccionado})` : 'Utilidad del Periodo'}
value={ingSel - dedSel}
icon={<Wallet className="h-4 w-4" />}
trend={(ingSel - dedSel) > 0 ? 'up' : 'down'}
subtitle="Ingresos - Deducciones"
/>
</div>
);
})()}
{/* ISR Info + Coeficiente */}
{(() => {
// Regímenes PF no usan coeficiente de utilidad
const REGIMENES_PF = ['605', '606', '612', '621', '625'];
const isResicoPF = regimenSeleccionado === '626'; // RESICO PF also doesn't use coeficiente
const showCoeficiente = !regimenSeleccionado || (!REGIMENES_PF.includes(regimenSeleccionado) && !isResicoPF);
return (
<div className={`grid gap-4 ${showCoeficiente ? 'lg:grid-cols-3' : ''}`}>
<Card className={showCoeficiente ? 'lg:col-span-2' : ''}>
<CardHeader>
<CardTitle className="text-base">Cálculo de ISR del Periodo</CardTitle>
</CardHeader>
<CardContent>
{(() => {
const desglose = resumenIsrDesglose;
if (!desglose) {
return <div className="text-sm text-muted-foreground">Cargando</div>;
}
const { delPeriodo, anteriores, total, mesFinal, anio } = desglose;
const labelMesFinal = `${meses[mesFinal - 1]} ${anio}`;
const labelAnteriores =
mesFinal === 1
? '(sin meses anteriores)'
: mesFinal === 2
? `(${meses[0]})`
: `(${meses[0]}-${meses[mesFinal - 2]})`;
// Resolver per-régimen si hay régimen seleccionado, igual patrón que antes.
const ingPer = regimenSeleccionado
? delPeriodo.ingresosPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: delPeriodo.ingresosAcumulados || 0;
const ingAnt = regimenSeleccionado
? anteriores.ingresosPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: anteriores.ingresosAcumulados || 0;
const dedPer = regimenSeleccionado
? delPeriodo.deduccionesPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: delPeriodo.deducciones || 0;
const dedAnt = regimenSeleccionado
? anteriores.deduccionesPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: anteriores.deducciones || 0;
const bgTotal = regimenSeleccionado
? total.baseGravablePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.baseGravable || 0
: total.baseGravable || 0;
const causadoTotal = regimenSeleccionado
? total.baseGravablePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.isrCausado || 0
: total.isrCausado || 0;
const retenido = total.isrRetenido || 0;
const aPagar = Math.max(0, causadoTotal - (regimenSeleccionado ? 0 : retenido));
return (
<div className="space-y-1">
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">Ingresos del periodo ({labelMesFinal})</span>
<span className="font-medium">{formatCurrency(ingPer)}</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">(+) Ingresos acumulados anteriores {labelAnteriores}</span>
<span className="font-medium">{formatCurrency(ingAnt)}</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">() Deducciones del periodo ({labelMesFinal})</span>
<span className="font-medium">{formatCurrency(dedPer)}</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">() Deducciones acumuladas anteriores {labelAnteriores}</span>
<span className="font-medium">{formatCurrency(dedAnt)}</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="font-medium">(=) Base gravable acumulada</span>
<span className={cn('font-medium', bgTotal < 0 ? 'text-destructive' : '')}>
{formatCurrency(bgTotal)}
</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">ISR causado (acumulado)</span>
<span className="font-medium">{formatCurrency(causadoTotal)}</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">() ISR retenido (acumulado)</span>
<span className="font-medium">{formatCurrency(regimenSeleccionado ? 0 : retenido)}</span>
</div>
<div className="flex justify-between py-2 bg-muted/50 px-4 rounded-lg mt-2">
<span className="font-medium">ISR a pagar</span>
<span className="font-bold text-lg">{formatCurrency(aPagar)}</span>
</div>
</div>
);
})()}
</CardContent>
</Card>
{showCoeficiente && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Settings className="h-4 w-4" />
Coeficiente de Utilidad {año}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Se utiliza para calcular el ISR de regimenes como General de Ley (601) y otros.
</p>
<div className="space-y-2">
<label className="text-sm font-medium">Coeficiente actual</label>
{coefData?.coeficiente !== null && coefData?.coeficiente !== undefined ? (
<p className="text-2xl font-bold">{coefData.coeficiente}</p>
) : (
<p className="text-sm text-destructive">No configurado</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Actualizar coeficiente</label>
<div className="flex gap-2">
<Input
type="number"
step="0.0001"
min="0"
max="1"
placeholder="Ej: 0.3521"
value={coefInput}
onChange={(e) => setCoefInput(e.target.value)}
className="h-9"
/>
<Button
size="sm"
disabled={!coefInput || savingCoef}
onClick={async () => {
const val = parseFloat(coefInput);
if (isNaN(val) || val < 0 || val > 1) return;
setSavingCoef(true);
try {
await setCoeficienteApi(año, val);
setCoefInput('');
queryClient.invalidateQueries({ queryKey: ['coeficiente'] });
queryClient.invalidateQueries({ queryKey: ['isr-resumen'] });
} catch {
alert('Error al guardar');
} finally {
setSavingCoef(false);
}
}}
>
{savingCoef ? 'Guardando...' : 'Guardar'}
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground">
Este valor se obtiene de la declaracion anual del ejercicio anterior. No se sobrescribe entre años.
</p>
</div>
</CardContent>
</Card>
)}
</div>
);
})()}
{/* ISR Monthly Table */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Histórico ISR {año}</CardTitle>
{isrMensual && isrMensual.length > 0 && (
<Button variant="outline" size="sm" onClick={() => exportToExcel(
isrMensual.map(r => ({
Mes: meses[r.mes - 1],
Ingresos: r.ingresosAcumulados,
'Ingresos Acumulados': r.ingresosAcum,
Deducciones: r.deducciones,
'Deducciones Acumuladas': r.deduccionesAcum,
'Base Gravable Acumulada': r.baseGravableAcum,
})),
[
{ header: 'Mes', key: 'Mes', width: 12 },
{ header: 'Ingresos', key: 'Ingresos', width: 18 },
{ header: 'Ingresos Acumulados', key: 'Ingresos Acumulados', width: 22 },
{ header: 'Deducciones', key: 'Deducciones', width: 18 },
{ header: 'Deducciones Acumuladas', key: 'Deducciones Acumuladas', width: 22 },
{ header: 'Base Gravable Acumulada', key: 'Base Gravable Acumulada', width: 22 },
],
`isr-mensual-${año}`,
)}>
<Download className="h-4 w-4 mr-1" /> Excel
</Button>
)}
</CardHeader>
<CardContent>
{isrLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b text-left text-sm text-muted-foreground">
<th className="pb-3 font-medium">Mes</th>
<th className="pb-3 font-medium text-right">Ingresos</th>
<th className="pb-3 font-medium text-right">Ingresos Acum.</th>
<th className="pb-3 font-medium text-right">Deducciones</th>
<th className="pb-3 font-medium text-right">Deducciones Acum.</th>
<th className="pb-3 font-medium text-right">Base Gravable Acum.</th>
</tr>
</thead>
<tbody className="text-sm">
{isrMensual?.map((row) => (
<tr key={row.mes} className="border-b hover:bg-muted/50">
<td className="py-3 font-medium">{meses[row.mes - 1]}</td>
<td className="py-3 text-right">{formatCurrency(row.ingresosAcumulados)}</td>
<td className="py-3 text-right">{formatCurrency(row.ingresosAcum)}</td>
<td className="py-3 text-right">{formatCurrency(row.deducciones)}</td>
<td className="py-3 text-right">{formatCurrency(row.deduccionesAcum)}</td>
<td className={cn(
'py-3 text-right font-medium',
row.baseGravableAcum < 0 ? 'text-destructive' : ''
)}>
{formatCurrency(row.baseGravableAcum)}
</td>
</tr>
))}
{(!isrMensual || isrMensual.length === 0) && (
<tr>
<td colSpan={6} className="py-8 text-center text-muted-foreground">
No hay registros de ISR para este año
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</>
)}
{activeTab === 'activos-fijos' && (
<ActivosFijosTab año={año} mes={mes} />
)}
<FiscalDisclaimer />
</main>
</>
);
}

View File

@@ -0,0 +1,86 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/auth-store';
import { useThemeStore } from '@/stores/theme-store';
import { themes } from '@/themes';
import { Sidebar } from '@/components/layouts/sidebar';
import { TopNav } from '@/components/layouts/topnav';
import { SidebarCompact } from '@/components/layouts/sidebar-compact';
import { SidebarFloating } from '@/components/layouts/sidebar-floating';
import { cn } from '@horux/shared-ui';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const { isAuthenticated, _hasHydrated } = useAuthStore();
const { theme } = useThemeStore();
const currentTheme = themes[theme];
const layout = currentTheme.layout;
useEffect(() => {
// Solo verificar autenticación después de que el store se rehidrate
if (_hasHydrated && !isAuthenticated) {
router.push('/login');
}
}, [isAuthenticated, _hasHydrated, router]);
// Mostrar loading mientras se rehidrata el store
if (!_hasHydrated) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="animate-pulse text-muted-foreground">Cargando...</div>
</div>
);
}
if (!isAuthenticated) {
return null;
}
// Render layout based on theme
const renderNavigation = () => {
switch (layout) {
case 'topnav':
return <TopNav />;
case 'sidebar-compact':
return <SidebarCompact />;
case 'sidebar-floating':
return <SidebarFloating />;
case 'sidebar-standard':
default:
return <Sidebar />;
}
};
const getContentClasses = () => {
switch (layout) {
case 'topnav':
return 'pt-16'; // Top padding for fixed top nav
case 'sidebar-compact':
return 'pl-16'; // Small left padding for compact sidebar
case 'sidebar-floating':
return 'pl-72 pr-4 py-4'; // Padding for floating sidebar
case 'sidebar-standard':
default:
return 'pl-64'; // Standard sidebar width
}
};
return (
<div className={cn(
'min-h-screen bg-background',
layout === 'sidebar-floating' && 'bg-gradient-to-br from-background via-background to-muted/20'
)}>
{renderNavigation()}
<div className={getContentClasses()}>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,275 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@horux/shared-ui';
import { getMyTenants, addMyTenant, type MyTenantDetailed } from '@/lib/api/tenants';
import { switchTenant } from '@/lib/api/auth';
import { useAuthStore } from '@/stores/auth-store';
import { formatCurrency } from '@/lib/utils';
import { Building2, Plus, Crown, ArrowRight, Loader2, AlertCircle, CheckCircle2 } from 'lucide-react';
const PLAN_LABELS: Record<string, string> = {
starter: 'Starter',
business: 'Business',
business_ia: 'Business + IA',
custom: 'Custom',
enterprise: 'Enterprise',
};
const STATUS_BADGES: Record<string, { label: string; className: string }> = {
authorized: { label: 'Activa', className: 'bg-green-100 text-green-700 border-green-200' },
trial: { label: 'Prueba', className: 'bg-blue-100 text-blue-700 border-blue-200' },
trial_converted: { label: 'Activa', className: 'bg-green-100 text-green-700 border-green-200' },
trial_expired: { label: 'Prueba vencida', className: 'bg-amber-100 text-amber-700 border-amber-200' },
pending: { label: 'Pendiente de pago', className: 'bg-amber-100 text-amber-700 border-amber-200' },
paused: { label: 'Pausada', className: 'bg-slate-100 text-slate-700 border-slate-200' },
cancelled: { label: 'Cancelada', className: 'bg-red-100 text-red-700 border-red-200' },
};
export default function MisEmpresasPage() {
const router = useRouter();
const queryClient = useQueryClient();
const { user, setUser, setTokens } = useAuthStore();
const [addOpen, setAddOpen] = useState(false);
const [form, setForm] = useState({ nombre: '', rfc: '', plan: 'starter' as const });
const [error, setError] = useState<string | null>(null);
const { data: tenants = [], isLoading } = useQuery({
queryKey: ['my-tenants'],
queryFn: getMyTenants,
});
const addMutation = useMutation({
mutationFn: addMyTenant,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['my-tenants'] });
setAddOpen(false);
setForm({ nombre: '', rfc: '', plan: 'starter' });
setError(null);
},
onError: (err: any) => {
setError(err?.response?.data?.message || 'Error al agregar empresa');
},
});
const handleSwitch = async (tenantId: string) => {
if (tenantId === user?.tenantId) {
router.push('/dashboard');
return;
}
try {
const res = await switchTenant(tenantId);
setTokens(res.accessToken, res.refreshToken);
setUser(res.user);
queryClient.clear();
router.push('/dashboard');
window.location.reload();
} catch (err: any) {
alert(err?.response?.data?.message || 'Error al cambiar de empresa');
}
};
const handleGoSuscripcion = async (tenantId: string) => {
if (tenantId !== user?.tenantId) {
await handleSwitch(tenantId);
// tras el reload el router pierde control
return;
}
router.push('/configuracion/suscripcion');
};
return (
<>
<Header title="Mis empresas" />
<main className="p-6 space-y-6">
<div className="flex items-start justify-between gap-4">
<p className="text-sm text-muted-foreground max-w-2xl">
Empresas que tienes bajo tu cuenta. Cada empresa tiene su propia suscripción
y datos fiscales. Usa el dropdown del header o el botón "Ir a esta empresa"
para cambiar de contexto.
</p>
<Button onClick={() => setAddOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> Agregar empresa
</Button>
</div>
{isLoading ? (
<div className="text-center py-12 text-muted-foreground">Cargando...</div>
) : tenants.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<Building2 className="h-12 w-12 text-muted-foreground/40 mx-auto mb-3" />
<p className="text-muted-foreground">No tienes empresas registradas.</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2">
{tenants.map(t => (
<TenantCard
key={t.tenantId}
tenant={t}
isActive={t.tenantId === user?.tenantId}
onSwitch={() => handleSwitch(t.tenantId)}
onGoSuscripcion={() => handleGoSuscripcion(t.tenantId)}
/>
))}
</div>
)}
<Dialog open={addOpen} onOpenChange={(open) => { if (!addMutation.isPending) setAddOpen(open); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Agregar empresa</DialogTitle>
<DialogDescription>
Registra una empresa adicional bajo tu cuenta. Te volverás owner automáticamente.
Al terminar, se te redirigirá a la página de contratación de plan.
</DialogDescription>
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault();
setError(null);
addMutation.mutate(form);
}}
className="space-y-4 py-2"
>
<div className="space-y-1">
<Label htmlFor="nombre">Nombre de la empresa</Label>
<Input
id="nombre"
value={form.nombre}
onChange={(e) => setForm({ ...form, nombre: e.target.value })}
required
minLength={2}
/>
</div>
<div className="space-y-1">
<Label htmlFor="rfc">RFC</Label>
<Input
id="rfc"
value={form.rfc}
onChange={(e) => setForm({ ...form, rfc: e.target.value.toUpperCase() })}
required
minLength={12}
maxLength={13}
className="font-mono uppercase"
placeholder="XAXX010101000"
/>
</div>
<div className="space-y-1">
<Label htmlFor="plan">Plan inicial</Label>
<Select value={form.plan} onValueChange={(v) => setForm({ ...form, plan: v as any })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="starter">Starter</SelectItem>
<SelectItem value="business">Business</SelectItem>
<SelectItem value="business_ia">Business + IA</SelectItem>
<SelectItem value="enterprise">Enterprise</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">Se contratará desde la nueva empresa. Sin prueba los RFCs adicionales requieren plan directo.</p>
</div>
{error && (
<div className="flex items-start gap-2 text-sm text-red-700 bg-red-50 border border-red-200 rounded px-3 py-2">
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
<span>{error}</span>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setAddOpen(false)} disabled={addMutation.isPending}>
Cancelar
</Button>
<Button type="submit" disabled={addMutation.isPending}>
{addMutation.isPending && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Crear empresa
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</main>
</>
);
}
interface TenantCardProps {
tenant: MyTenantDetailed;
isActive: boolean;
onSwitch: () => void;
onGoSuscripcion: () => void;
}
function TenantCard({ tenant, isActive, onSwitch, onGoSuscripcion }: TenantCardProps) {
const sub = tenant.subscription;
const statusBadge = sub ? STATUS_BADGES[sub.status] || { label: sub.status, className: 'bg-slate-100 text-slate-700 border-slate-200' } : null;
return (
<Card className={isActive ? 'border-primary/50' : ''}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<CardTitle className="text-base flex items-center gap-2">
<span className="truncate">{tenant.nombre}</span>
{tenant.isOwner && <Crown className="h-4 w-4 text-amber-500 flex-shrink-0" />}
</CardTitle>
<p className="text-xs text-muted-foreground font-mono mt-0.5">{tenant.rfc}</p>
</div>
{isActive && (
<span className="text-xs text-primary font-medium flex items-center gap-1 flex-shrink-0">
<CheckCircle2 className="h-3 w-3" /> Activa
</span>
)}
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-3 text-sm">
<div>
<p className="text-xs text-muted-foreground">Plan</p>
<p className="font-medium">{PLAN_LABELS[tenant.plan] || tenant.plan}</p>
</div>
{statusBadge && (
<div>
<p className="text-xs text-muted-foreground">Estado</p>
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium border ${statusBadge.className}`}>
{statusBadge.label}
</span>
</div>
)}
{sub?.amount && sub.amount > 0 && (
<div>
<p className="text-xs text-muted-foreground">{sub.frequency === 'annual' ? 'Anual' : 'Mensual'}</p>
<p className="font-medium">{formatCurrency(sub.amount)}</p>
</div>
)}
</div>
{sub?.currentPeriodEnd && (
<p className="text-xs text-muted-foreground">
Próximo cobro: {new Date(sub.currentPeriodEnd).toLocaleDateString('es-MX')}
</p>
)}
{sub?.pendingPlan && sub?.pendingEffectiveAt && (
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-2 py-1">
Cambio a {PLAN_LABELS[sub.pendingPlan] || sub.pendingPlan} programado para {new Date(sub.pendingEffectiveAt).toLocaleDateString('es-MX')}
</p>
)}
<div className="flex gap-2 pt-1">
{!isActive && (
<Button size="sm" variant="outline" onClick={onSwitch} className="flex-1">
Ir a esta empresa <ArrowRight className="h-3 w-3 ml-1" />
</Button>
)}
<Button size="sm" variant={isActive ? 'default' : 'ghost'} onClick={onGoSuscripcion} className={isActive ? 'flex-1' : ''}>
Ver suscripción
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,184 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Button, Card, CardContent, cn } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import { useContribuyentes } from '@/lib/hooks/use-contribuyentes';
import { apiClient } from '@/lib/api/client';
import { CheckCircle2, ArrowRight, Building2, Key, FileText, Users, CreditCard } from 'lucide-react';
interface Step {
id: string;
title: string;
description: string;
icon: React.ReactNode;
href: string;
completed: boolean;
optional?: boolean;
}
export default function OnboardingPage() {
const user = useAuthStore((s) => s.user);
const { data: contribuyentes } = useContribuyentes();
const router = useRouter();
const [fielDone, setFielDone] = useState(false);
const [csdDone, setCsdDone] = useState(false);
const hasContribuyentes = (contribuyentes?.length ?? 0) > 0;
const firstContribId = contribuyentes?.[0]?.id;
// Check FIEL + Facturapi status — try per-contribuyente first, fallback to legacy tenant-level
useEffect(() => {
if (!firstContribId) return;
// FIEL: check per-contribuyente (tenant BD) then legacy (central BD)
apiClient.get(`/contribuyentes/${firstContribId}/fiel/status`)
.then(({ data }) => {
if (data.configured) {
setFielDone(true);
} else {
// Fallback: check legacy tenant-level FIEL
apiClient.get('/fiel/status')
.then(({ data: legacyData }) => setFielDone(legacyData.configured === true))
.catch(() => setFielDone(false));
}
})
.catch(() => {
apiClient.get('/fiel/status')
.then(({ data: legacyData }) => setFielDone(legacyData.configured === true))
.catch(() => setFielDone(false));
});
// Facturapi: check per-contribuyente then legacy
apiClient.get(`/contribuyentes/${firstContribId}/facturapi/status`)
.then(({ data }) => {
if (data.configured) {
setCsdDone(data.hasCsd === true);
} else {
apiClient.get('/facturacion/org/status')
.then(({ data: legacyData }) => setCsdDone(legacyData.configured === true && legacyData.hasCsd === true))
.catch(() => setCsdDone(false));
}
})
.catch(() => setCsdDone(false));
}, [firstContribId]);
const steps: Step[] = [
{
id: 'account',
title: 'Cuenta creada',
description: 'Tu despacho está registrado y listo.',
icon: <CheckCircle2 className="h-5 w-5" />,
href: '#',
completed: true,
},
{
id: 'contribuyente',
title: 'Agregar primer contribuyente',
description: 'Registra el primer RFC que gestionarás.',
icon: <Building2 className="h-5 w-5" />,
href: '/contribuyentes',
completed: hasContribuyentes,
},
{
id: 'fiel',
title: 'Subir FIEL del contribuyente',
description: 'Necesaria para sincronizar con el SAT.',
icon: <Key className="h-5 w-5" />,
href: '/contribuyentes',
completed: fielDone,
},
{
id: 'csd',
title: 'Subir CSD (para emitir facturas)',
description: 'Certificado de Sello Digital para timbrado.',
icon: <FileText className="h-5 w-5" />,
href: '/contribuyentes',
completed: csdDone,
},
{
id: 'team',
title: 'Invitar supervisores o auxiliares',
description: 'Agrega a tu equipo de trabajo.',
icon: <Users className="h-5 w-5" />,
href: '/usuarios',
completed: false,
optional: true,
},
{
id: 'plan',
title: 'Elegir plan de pago',
description: 'Tu trial gratuito dura 30 días.',
icon: <CreditCard className="h-5 w-5" />,
href: '/configuracion/planes-despacho',
completed: false,
optional: true,
},
];
const completedCount = steps.filter((s) => s.completed).length;
const requiredSteps = steps.filter((s) => !s.optional);
const requiredCompleted = requiredSteps.filter((s) => s.completed).length;
const allRequiredDone = requiredCompleted === requiredSteps.length;
return (
<div className="p-6 max-w-2xl mx-auto space-y-6">
<div className="text-center space-y-2">
<h1 className="text-3xl font-bold">Bienvenido a Horux Despachos</h1>
<p className="text-muted-foreground">
Configura tu despacho en unos minutos. {completedCount} de {steps.length} pasos completados.
</p>
<div className="w-full bg-muted rounded-full h-2 mt-4">
<div
className="bg-primary rounded-full h-2 transition-all"
style={{ width: `${(completedCount / steps.length) * 100}%` }}
/>
</div>
</div>
<div className="space-y-3">
{steps.map((step) => (
<Card key={step.id} className={cn(step.completed && 'opacity-60')}>
<CardContent className="flex items-center gap-4 py-4 px-6">
<div className={cn(
'flex-shrink-0 rounded-full p-2',
step.completed ? 'bg-green-100 text-green-600 dark:bg-green-900 dark:text-green-400' : 'bg-muted text-muted-foreground'
)}>
{step.completed ? <CheckCircle2 className="h-5 w-5" /> : step.icon}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium flex items-center gap-2">
{step.title}
{step.optional && <span className="text-xs text-muted-foreground font-normal">(opcional)</span>}
</p>
<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">
Configurar <ArrowRight className="h-3 w-3" />
</Button>
</Link>
)}
</CardContent>
</Card>
))}
</div>
{allRequiredDone && (
<div className="text-center pt-4">
<Button onClick={() => router.push('/dashboard')} size="lg">
Ir al Dashboard
</Button>
</div>
)}
<p className="text-center text-xs text-muted-foreground pt-4">
Puedes completar estos pasos en cualquier orden. Tu trial de 30 días ya comenzó.
</p>
</div>
);
}

View File

@@ -0,0 +1,466 @@
'use client';
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle, Button, cn } from '@horux/shared-ui';
import { PeriodSelector } from '@horux/shared-ui';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { useContribuyentes } from '@/lib/hooks/use-contribuyentes';
import { useAuthStore } from '@/stores/auth-store';
import { apiClient } from '@/lib/api/client';
import { Header } from '@/components/layouts/header';
import {
ClipboardList,
CheckCircle2,
Circle,
Clock,
AlertTriangle,
Building2,
} from 'lucide-react';
interface DeclaracionLink {
id: number;
año: number;
mes: number;
tipo: 'normal' | 'complementaria';
pdfFilename: string | null;
}
interface ObligacionPeriodo {
id: string;
nombre: string;
frecuencia: string | null;
fechaLimite: string | null;
categoria: string | null;
activa: boolean;
esRecomendada: boolean;
completada: boolean;
completadaAt: string | null;
completadaPor: string | null;
periodoCompletado: string | null;
periodStatus: 'pendiente' | 'completada' | 'atrasada';
periodoAplica: string;
/** Cuando la obligación fue completada al subir una declaración, apunta a ella. */
declaracion: DeclaracionLink | null;
}
interface ContribuyenteResumen {
id: string;
rfc: string;
nombre: string;
total: number;
completadas: number;
atrasadas: number;
pendientes: number;
obligaciones: ObligacionPeriodo[];
}
export default function PendientesPage() {
const { selectedContribuyenteId, setSelectedContribuyente } = useContribuyenteStore();
const { data: contribuyentes } = useContribuyentes();
const user = useAuthStore((s) => s.user);
const now = new Date();
const [periodo, setPeriodo] = useState(() => {
const y = now.getFullYear();
const m = now.getMonth() + 1;
return `${y}-${String(m).padStart(2, '0')}`;
});
// Derive fechaInicio/fechaFin for PeriodSelector
const fechaInicio = `${periodo}-01`;
const lastDay = new Date(parseInt(periodo.split('-')[0]), parseInt(periodo.split('-')[1]), 0).getDate();
const fechaFin = `${periodo}-${String(lastDay).padStart(2, '0')}`;
const [resumenes, setResumenes] = useState<ContribuyenteResumen[]>([]);
const [loading, setLoading] = useState(true);
const [singleObligaciones, setSingleObligaciones] = useState<ObligacionPeriodo[]>([]);
const [filter, setFilter] = useState<'todos' | 'mis'>('todos');
const [toggling, setToggling] = useState<string | null>(null);
// Single contribuyente view — fetch period-aware data
useEffect(() => {
if (!selectedContribuyenteId) return;
setLoading(true);
apiClient.get(`/contribuyentes/${selectedContribuyenteId}/obligaciones/periodo?periodo=${periodo}&atrasados=true`)
.then(({ data }) => setSingleObligaciones(data.data || []))
.catch(() => setSingleObligaciones([]))
.finally(() => setLoading(false));
}, [selectedContribuyenteId, periodo]);
// Portfolio view — fetch period-aware data for all contribuyentes
useEffect(() => {
if (selectedContribuyenteId) return;
if (!contribuyentes || contribuyentes.length === 0) {
setLoading(false);
return;
}
setLoading(true);
Promise.all(
contribuyentes.map(async (c) => {
try {
const { data } = await apiClient.get(`/contribuyentes/${c.id}/obligaciones/periodo?periodo=${periodo}&atrasados=true`);
const items: ObligacionPeriodo[] = data.data || [];
return {
id: c.id, rfc: c.rfc, nombre: c.nombre,
total: items.length,
completadas: items.filter((o) => o.periodStatus === 'completada').length,
atrasadas: items.filter((o) => o.periodStatus === 'atrasada').length,
pendientes: items.filter((o) => o.periodStatus === 'pendiente').length,
obligaciones: items,
};
} catch {
return { id: c.id, rfc: c.rfc, nombre: c.nombre, total: 0, completadas: 0, atrasadas: 0, pendientes: 0, obligaciones: [] };
}
})
)
.then(setResumenes)
.finally(() => setLoading(false));
}, [selectedContribuyenteId, contribuyentes, periodo]);
// Filter portfolio: "Mis asignados" shows only the contribuyentes visible to the current user.
// For supervisors: their cartera contribuyentes (already filtered by useContribuyentes).
// For owners: all contribuyentes (no filter needed).
// Since useContribuyentes already filters by role, "Mis asignados" for non-owner
// is effectively the same as "Todos" (they only see their assigned ones).
const filteredResumenes = filter === 'mis' && user && contribuyentes
? resumenes.filter((r) => contribuyentes.some((c) => c.id === r.id))
: resumenes;
// Derived counts for single view
const completadasCount = singleObligaciones.filter((o) => o.periodStatus === 'completada').length;
const atrasadasCount = singleObligaciones.filter((o) => o.periodStatus === 'atrasada').length;
const pendientesCount = singleObligaciones.filter((o) => o.periodStatus === 'pendiente').length;
const categorias = [...new Set(singleObligaciones.map((o) => o.categoria || 'Sin categoría'))];
const toggleComplete = async (obligacionId: string, currentStatus: string, periodoAplica: string) => {
if (!selectedContribuyenteId) return;
const key = `${obligacionId}:${periodoAplica}`;
setToggling(key);
try {
if (currentStatus === 'completada') {
await apiClient.post(
`/contribuyentes/${selectedContribuyenteId}/obligaciones/${obligacionId}/uncomplete-periodo`,
{ periodo: periodoAplica }
);
} else {
await apiClient.post(
`/contribuyentes/${selectedContribuyenteId}/obligaciones/${obligacionId}/complete-periodo`,
{ periodo: periodoAplica }
);
}
// Refetch
const { data } = await apiClient.get(`/contribuyentes/${selectedContribuyenteId}/obligaciones/periodo?periodo=${periodo}&atrasados=true`);
setSingleObligaciones(data.data || []);
} catch {
// silent — state stays as-is
} finally {
setToggling(null);
}
};
// Status badge
const statusBadge = (status: string) => {
if (status === 'completada') return <span className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300">Completada</span>;
if (status === 'atrasada') return <span className="text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300">Atrasada</span>;
return <span className="text-xs px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300">Pendiente</span>;
};
// Frecuencia badge
const frecBadge = (f: string | null) => {
const colors: Record<string, string> = {
mensual: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
bimestral: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
trimestral: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
anual: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
};
return f ? (
<span className={cn('text-xs px-2 py-0.5 rounded-full', colors[f] || 'bg-gray-100 text-gray-700')}>{f}</span>
) : null;
};
// Progress bar for a resumen row
const ProgressBar = ({ r }: { r: ContribuyenteResumen }) => {
const pct = r.total > 0 ? Math.round((r.completadas / r.total) * 100) : 0;
if (r.total === 0) return null;
return (
<div className="w-36">
<div className="flex justify-between text-xs mb-1 text-muted-foreground">
<span>{r.completadas}/{r.total}</span>
<span>{pct}%</span>
</div>
<div className="w-full bg-muted rounded-full h-2">
<div
className={cn(
'rounded-full h-2 transition-all',
pct === 100 ? 'bg-green-500' : pct > 50 ? 'bg-blue-500' : 'bg-amber-500'
)}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
};
return (
<>
<Header title="Pendientes" />
<main className="p-6 space-y-6">
{loading ? (
<div className="text-center py-12 text-muted-foreground">Cargando...</div>
) : !contribuyentes || contribuyentes.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold">Sin contribuyentes</h3>
<p className="text-sm text-muted-foreground">Agrega contribuyentes para ver sus pendientes.</p>
</CardContent>
</Card>
) : selectedContribuyenteId ? (
/* =============== SINGLE CONTRIBUYENTE VIEW =============== */
<>
{/* Period selector */}
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">Periodo:</span>
<PeriodSelector
fechaInicio={fechaInicio}
fechaFin={fechaFin}
onChange={(fi) => setPeriodo(fi.substring(0, 7))}
/>
</div>
{/* Summary cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="bg-blue-100 dark:bg-blue-900 rounded-full p-2">
<ClipboardList className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<p className="text-2xl font-bold">{singleObligaciones.length}</p>
<p className="text-xs text-muted-foreground">Total periodo</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="bg-red-100 dark:bg-red-900 rounded-full p-2">
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400" />
</div>
<div>
<p className="text-2xl font-bold">{atrasadasCount}</p>
<p className="text-xs text-muted-foreground">Atrasadas</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="bg-amber-100 dark:bg-amber-900 rounded-full p-2">
<Clock className="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
<div>
<p className="text-2xl font-bold">{pendientesCount}</p>
<p className="text-xs text-muted-foreground">Pendientes</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="bg-green-100 dark:bg-green-900 rounded-full p-2">
<CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-2xl font-bold">{completadasCount}</p>
<p className="text-xs text-muted-foreground">Completadas</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Obligations by category */}
{singleObligaciones.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
<AlertTriangle className="h-8 w-8 mx-auto mb-2 text-amber-500" />
<p className="font-medium">Sin obligaciones para este periodo</p>
<p className="text-sm mt-1">Ve a Configuración Obligaciones Fiscales para generar recomendaciones.</p>
</CardContent>
</Card>
) : (
categorias.map((cat) => (
<Card key={cat}>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground uppercase tracking-wide">{cat}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{singleObligaciones.filter((o) => (o.categoria || 'Sin categoría') === cat).map((ob) => {
const toggleKey = `${ob.id}:${ob.periodoAplica}`;
return (
<div
key={toggleKey}
className={cn(
'flex items-center justify-between py-2 border-b last:border-0',
ob.periodStatus === 'completada' && 'opacity-60'
)}
>
<div className="flex items-center gap-3">
<button
onClick={() => toggleComplete(ob.id, ob.periodStatus, ob.periodoAplica)}
disabled={toggling === toggleKey}
className="shrink-0 focus:outline-none"
title={ob.periodStatus === 'completada' ? 'Marcar como pendiente' : 'Marcar como completada'}
>
{ob.periodStatus === 'completada' ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<Circle className={cn('h-4 w-4', ob.periodStatus === 'atrasada' ? 'text-red-400' : 'text-muted-foreground')} />
)}
</button>
<div>
<p className={cn('text-sm font-medium', ob.periodStatus === 'completada' && 'line-through')}>{ob.nombre}</p>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
<p className="text-xs text-muted-foreground">{ob.fechaLimite}</p>
{ob.periodoAplica !== periodo && (
<span className="text-xs text-red-500 font-medium">({ob.periodoAplica})</span>
)}
{ob.declaracion && (
<a
href={`${process.env.NEXT_PUBLIC_API_URL}/documentos/declaraciones/${ob.declaracion.id}/pdf/declaracion`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:text-blue-700 hover:underline inline-flex items-center gap-1"
title={`Declaración ${ob.declaracion.tipo} ${String(ob.declaracion.mes).padStart(2, '0')}/${ob.declaracion.año}${ob.declaracion.pdfFilename ?? 'PDF'}`}
onClick={(e) => e.stopPropagation()}
>
Declaración {String(ob.declaracion.mes).padStart(2, '0')}/{ob.declaracion.año}
{ob.declaracion.tipo === 'complementaria' && <span className="text-[10px] uppercase font-semibold">Compl.</span>}
</a>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{frecBadge(ob.frecuencia)}
{statusBadge(ob.periodStatus)}
</div>
</div>
);
})}
</CardContent>
</Card>
))
)}
</>
) : (
/* =============== ALL CONTRIBUYENTES VIEW =============== */
<>
{/* Period selector + filter bar */}
<div className="flex items-center gap-4 flex-wrap">
<span className="text-sm text-muted-foreground">Periodo:</span>
<PeriodSelector
fechaInicio={fechaInicio}
fechaFin={fechaFin}
onChange={(fi) => setPeriodo(fi.substring(0, 7))}
/>
<div className="flex rounded-lg border overflow-hidden text-sm ml-2">
<button
onClick={() => setFilter('todos')}
className={cn(
'px-3 py-1.5 transition-colors',
filter === 'todos'
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted'
)}
>
Todos
</button>
<button
onClick={() => setFilter('mis')}
className={cn(
'px-3 py-1.5 border-l transition-colors',
filter === 'mis'
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted'
)}
>
Mis asignados
</button>
</div>
<span className="text-sm text-muted-foreground">
{filteredResumenes.length} contribuyente{filteredResumenes.length !== 1 ? 's' : ''}
</span>
</div>
<p className="text-sm text-muted-foreground">
Resumen de obligaciones por contribuyente para el periodo seleccionado. Selecciona uno para ver el detalle.
</p>
{filteredResumenes.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
<Building2 className="h-8 w-8 mx-auto mb-2" />
<p className="font-medium">
{filter === 'mis' ? 'No tienes contribuyentes asignados' : 'Sin contribuyentes'}
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{filteredResumenes.map((r) => (
<Card
key={r.id}
className="cursor-pointer hover:border-primary/50 transition-colors"
onClick={() => setSelectedContribuyente(r.id, r.rfc, r.nombre)}
>
<CardContent className="flex items-center justify-between py-4 px-6">
<div className="flex items-center gap-4">
<div className="h-10 w-10 rounded-lg bg-muted flex items-center justify-center text-sm font-bold">
{r.nombre.substring(0, 2).toUpperCase()}
</div>
<div>
<p className="font-semibold">{r.nombre}</p>
<p className="text-sm text-muted-foreground font-mono">{r.rfc}</p>
</div>
</div>
<div className="flex items-center gap-6">
{r.total > 0 ? (
<>
<ProgressBar r={r} />
<div className="flex items-center gap-3 text-right">
{r.atrasadas > 0 && (
<div className="text-right">
<p className="text-lg font-bold text-red-600">{r.atrasadas}</p>
<p className="text-xs text-muted-foreground">atrasadas</p>
</div>
)}
<div className="text-right">
<p className="text-lg font-bold">{r.pendientes}</p>
<p className="text-xs text-muted-foreground">pendientes</p>
</div>
</div>
</>
) : (
<div className="flex items-center gap-1 text-amber-500">
<AlertTriangle className="h-4 w-4" />
<span className="text-xs">Sin configurar</span>
</div>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
</>
)}
</main>
</>
);
}

View File

@@ -0,0 +1,442 @@
'use client';
import { useState } from 'react';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle, Tabs, TabsContent, TabsList, TabsTrigger } from '@horux/shared-ui';
import { PeriodSelector, RegimenSelector, KpiCard } from '@horux/shared-ui';
import { useEstadoResultados, useFlujoEfectivo, useComparativo, useConcentradoRfc, useCuentasXPagar, useCuentasXCobrar } from '@/lib/hooks/use-reportes';
import { useRegimenesDelPeriodo } from '@/lib/hooks/use-dashboard';
import { BarChart } from '@/components/charts/bar-chart';
import { formatCurrency } from '@/lib/utils';
import { FileText, TrendingUp, TrendingDown, Users, CreditCard, Banknote } from 'lucide-react';
import { FiscalDisclaimer } from '@/components/fiscal-disclaimer';
export default function ReportesPage() {
const now = new Date();
const [fechaInicio, setFechaInicio] = useState(`${now.getFullYear()}-01-01`);
const [fechaFin, setFechaFin] = useState(`${now.getFullYear()}-12-31`);
const [regimenSeleccionado, setRegimenSeleccionado] = useState<string | null>(null);
const año = new Date(fechaInicio + 'T00:00:00').getFullYear();
const { data: regimenesPeriodo, isLoading: regimenesLoading } = useRegimenesDelPeriodo(fechaInicio, fechaFin);
const { data: estadoResultados, isLoading: loadingER, error: errorER } = useEstadoResultados(fechaInicio, fechaFin);
const regimenesDisponibles = regimenesPeriodo || [];
if (regimenSeleccionado && regimenesDisponibles.length > 0 &&
!regimenesDisponibles.find(r => r.clave === regimenSeleccionado)) {
setRegimenSeleccionado(null);
}
const { data: flujoEfectivo, isLoading: loadingFE, error: errorFE } = useFlujoEfectivo(fechaInicio, fechaFin);
const { data: comparativo, isLoading: loadingComp, error: errorComp } = useComparativo(año);
const { data: clientes, error: errorClientes } = useConcentradoRfc('cliente', fechaInicio, fechaFin);
const { data: proveedores, error: errorProveedores } = useConcentradoRfc('proveedor', fechaInicio, fechaFin);
const { data: cxp, isLoading: loadingCXP } = useCuentasXPagar(fechaInicio, fechaFin, regimenSeleccionado || undefined);
const { data: cxc, isLoading: loadingCXC } = useCuentasXCobrar(fechaInicio, fechaFin, regimenSeleccionado || undefined);
return (
<DashboardShell
title="Reportes"
headerContent={
<PeriodSelector
fechaInicio={fechaInicio}
fechaFin={fechaFin}
onChange={(fi, ff) => { setFechaInicio(fi); setFechaFin(ff); }}
/>
}
>
<div className="mb-4">
<RegimenSelector
regimenes={regimenesDisponibles}
selected={regimenSeleccionado}
onChange={setRegimenSeleccionado}
isLoading={regimenesLoading}
/>
</div>
<Tabs defaultValue="estado-resultados" className="space-y-4">
<TabsList>
<TabsTrigger value="estado-resultados">Estado de Resultados</TabsTrigger>
<TabsTrigger value="flujo-efectivo">Flujo de Efectivo</TabsTrigger>
<TabsTrigger value="comparativo">Comparativo</TabsTrigger>
<TabsTrigger value="concentrado">Concentrado RFC</TabsTrigger>
<TabsTrigger value="cxp">Cuentas X Pagar</TabsTrigger>
<TabsTrigger value="cxc">Cuentas X Cobrar</TabsTrigger>
</TabsList>
<TabsContent value="estado-resultados" className="space-y-4">
{loadingER ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : errorER ? (
<div className="text-center py-8 text-destructive">Error: {(errorER as Error).message}</div>
) : !estadoResultados ? (
<div className="text-center py-8 text-muted-foreground">No hay datos disponibles para el período seleccionado</div>
) : (
<>
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Total Ingresos</CardTitle>
<TrendingUp className="h-4 w-4 text-success" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-success">
{formatCurrency(estadoResultados.totalIngresos)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Total Egresos</CardTitle>
<TrendingDown className="h-4 w-4 text-destructive" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-destructive">
{formatCurrency(estadoResultados.totalEgresos)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Utilidad Bruta</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${estadoResultados.utilidadBruta >= 0 ? 'text-success' : 'text-destructive'}`}>
{formatCurrency(estadoResultados.utilidadBruta)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Utilidad Neta</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${estadoResultados.utilidadNeta >= 0 ? 'text-success' : 'text-destructive'}`}>
{formatCurrency(estadoResultados.utilidadNeta)}
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Top 10 Ingresos por Cliente</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{estadoResultados.ingresos.map((item, i) => (
<div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
<span className="text-sm truncate max-w-[200px]">{item.concepto}</span>
<span className="font-medium">{formatCurrency(item.monto)}</span>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Top 10 Egresos por Proveedor</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{estadoResultados.egresos.map((item, i) => (
<div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
<span className="text-sm truncate max-w-[200px]">{item.concepto}</span>
<span className="font-medium">{formatCurrency(item.monto)}</span>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</>
)}
</TabsContent>
<TabsContent value="flujo-efectivo" className="space-y-4">
{loadingFE ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : errorFE ? (
<div className="text-center py-8 text-destructive">Error: {(errorFE as Error).message}</div>
) : !flujoEfectivo ? (
<div className="text-center py-8 text-muted-foreground">No hay datos de flujo de efectivo</div>
) : (
<>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Total Entradas</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-success">
{formatCurrency(flujoEfectivo.totalEntradas)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Total Salidas</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-destructive">
{formatCurrency(flujoEfectivo.totalSalidas)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Flujo Neto</CardTitle>
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${flujoEfectivo.flujoNeto >= 0 ? 'text-success' : 'text-destructive'}`}>
{formatCurrency(flujoEfectivo.flujoNeto)}
</div>
</CardContent>
</Card>
</div>
<BarChart
title="Flujo de Efectivo Mensual"
data={flujoEfectivo.entradas.map((e, i) => ({
mes: e.concepto,
ingresos: e.monto,
egresos: flujoEfectivo.salidas[i]?.monto || 0,
}))}
/>
</>
)}
</TabsContent>
<TabsContent value="comparativo" className="space-y-4">
{loadingComp ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : errorComp ? (
<div className="text-center py-8 text-destructive">Error: {(errorComp as Error).message}</div>
) : !comparativo ? (
<div className="text-center py-8 text-muted-foreground">No hay datos comparativos</div>
) : (
<>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Var. Ingresos vs Año Anterior</CardTitle>
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${comparativo.variacionIngresos >= 0 ? 'text-success' : 'text-destructive'}`}>
{comparativo.variacionIngresos >= 0 ? '+' : ''}{comparativo.variacionIngresos.toFixed(1)}%
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Var. Egresos vs Año Anterior</CardTitle>
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${comparativo.variacionEgresos <= 0 ? 'text-success' : 'text-destructive'}`}>
{comparativo.variacionEgresos >= 0 ? '+' : ''}{comparativo.variacionEgresos.toFixed(1)}%
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Año Actual</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{año}</div>
</CardContent>
</Card>
</div>
<BarChart
title={`Comparativo Mensual ${año}`}
data={comparativo.periodos.map((mes, i) => ({
mes,
ingresos: comparativo.ingresos[i],
egresos: comparativo.egresos[i],
}))}
/>
</>
)}
</TabsContent>
<TabsContent value="concentrado" className="space-y-4">
{errorClientes || errorProveedores ? (
<div className="text-center py-8 text-destructive">
Error: {((errorClientes || errorProveedores) as Error).message}
</div>
) : (
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Clientes
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{clientes && clientes.length > 0 ? (
clientes.slice(0, 10).map((c, i) => (
<div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
<div>
<div className="font-medium text-sm">{c.nombre}</div>
<div className="text-xs text-muted-foreground">{c.rfc} - {c.cantidadCfdis} CFDIs</div>
</div>
<span className="font-medium">{formatCurrency(c.totalFacturado)}</span>
</div>
))
) : (
<div className="text-center py-4 text-muted-foreground text-sm">Sin clientes</div>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Proveedores
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{proveedores && proveedores.length > 0 ? (
proveedores.slice(0, 10).map((p, i) => (
<div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
<div>
<div className="font-medium text-sm">{p.nombre}</div>
<div className="text-xs text-muted-foreground">{p.rfc} - {p.cantidadCfdis} CFDIs</div>
</div>
<span className="font-medium">{formatCurrency(p.totalFacturado)}</span>
</div>
))
) : (
<div className="text-center py-4 text-muted-foreground text-sm">Sin proveedores</div>
)}
</div>
</CardContent>
</Card>
</div>
)}
</TabsContent>
<TabsContent value="cxp" className="space-y-4">
{loadingCXP ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : (
<>
<div className="grid gap-4 md:grid-cols-2">
<KpiCard
title="CFDIs con Saldo Pendiente"
value={String(cxp?.cantidadCfdis || 0)}
icon={<FileText className="h-4 w-4" />}
/>
<KpiCard
title="Saldo Pendiente Total"
value={cxp?.saldoTotal || 0}
icon={<CreditCard className="h-4 w-4" />}
trend={(cxp?.saldoTotal || 0) > 0 ? 'up' : 'neutral'}
trendValue="Por pagar"
/>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Saldo por Proveedor</CardTitle>
</CardHeader>
<CardContent>
{cxp?.detalle.length === 0 ? (
<p className="text-center py-8 text-muted-foreground">No hay cuentas por pagar en el periodo</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-3 font-medium">RFC</th>
<th className="pb-3 font-medium">Nombre</th>
<th className="pb-3 font-medium text-right">CFDIs</th>
<th className="pb-3 font-medium text-right">Saldo Pendiente</th>
</tr>
</thead>
<tbody>
{cxp?.detalle.map((d) => (
<tr key={d.rfc} className="border-b hover:bg-muted/50">
<td className="py-3 font-mono text-xs">{d.rfc}</td>
<td className="py-3">{d.nombre}</td>
<td className="py-3 text-right">{d.cantidad}</td>
<td className="py-3 text-right font-medium">{formatCurrency(d.saldo)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</>
)}
</TabsContent>
<TabsContent value="cxc" className="space-y-4">
{loadingCXC ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : (
<>
<div className="grid gap-4 md:grid-cols-2">
<KpiCard
title="CFDIs con Saldo Pendiente"
value={String(cxc?.cantidadCfdis || 0)}
icon={<FileText className="h-4 w-4" />}
/>
<KpiCard
title="Saldo Pendiente Total"
value={cxc?.saldoTotal || 0}
icon={<Banknote className="h-4 w-4" />}
trend={(cxc?.saldoTotal || 0) > 0 ? 'up' : 'neutral'}
trendValue="Por cobrar"
/>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Saldo por Cliente</CardTitle>
</CardHeader>
<CardContent>
{cxc?.detalle.length === 0 ? (
<p className="text-center py-8 text-muted-foreground">No hay cuentas por cobrar en el periodo</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-3 font-medium">RFC</th>
<th className="pb-3 font-medium">Nombre</th>
<th className="pb-3 font-medium text-right">CFDIs</th>
<th className="pb-3 font-medium text-right">Saldo Pendiente</th>
</tr>
</thead>
<tbody>
{cxc?.detalle.map((d) => (
<tr key={d.rfc} className="border-b hover:bg-muted/50">
<td className="py-3 font-mono text-xs">{d.rfc}</td>
<td className="py-3">{d.nombre}</td>
<td className="py-3 text-right">{d.cantidad}</td>
<td className="py-3 text-right font-medium">{formatCurrency(d.saldo)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</>
)}
</TabsContent>
</Tabs>
<FiscalDisclaimer />
</DashboardShell>
);
}

View File

@@ -0,0 +1,511 @@
'use client';
import { useState } from 'react';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui';
import { useUsuarios, useInviteUsuario, useUpdateUsuario, useDeleteUsuario } from '@/lib/hooks/use-usuarios';
import { useContribuyentes } from '@/lib/hooks/use-contribuyentes';
import { addClienteAcceso } from '@/lib/api/contribuyentes';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
import { useAuthStore } from '@/stores/auth-store';
import { Users, UserPlus, Trash2, Shield, Eye, Calculator, UserCheck, UserCog, Building2, FolderOpen, KeyRound } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@horux/shared-ui';
import Link from 'next/link';
import { cn } from '@horux/shared-ui';
import { isDespachoTenant } from '@horux/shared';
import type { Role } from '@horux/shared';
// ── Horux360 legacy roles ─────────────────────────────────────────────────────
const legacyRoleLabels: Record<string, { label: string; icon: React.ElementType; color: string }> = {
owner: { label: 'Dueño', icon: Shield, color: 'text-primary' },
cfo: { label: 'CFO', icon: Shield, color: 'text-primary' },
contador: { label: 'Contador', icon: Calculator, color: 'text-success' },
auxiliar: { label: 'Auxiliar', icon: UserCog, color: 'text-success' },
visor: { label: 'Visor', icon: Eye, color: 'text-muted-foreground' },
};
const legacyInviteRoles: { value: string; label: string }[] = [
{ value: 'contador', label: 'Contador' },
{ value: 'visor', label: 'Visor' },
{ value: 'auxiliar', label: 'Auxiliar' },
];
// ── Despacho roles ────────────────────────────────────────────────────────────
const despachoRoleLabels: Record<string, { label: string; icon: React.ElementType; color: string }> = {
owner: { label: 'Owner', icon: Shield, color: 'text-primary' },
supervisor: { label: 'Supervisor', icon: UserCheck, color: 'text-success' },
auxiliar: { label: 'Auxiliar', icon: UserCog, color: 'text-success' },
cliente: { label: 'Cliente', icon: Building2, color: 'text-muted-foreground' },
};
const despachoInviteRoles: { value: string; label: string; description: string }[] = [
{
value: 'supervisor',
label: 'Supervisor',
description: 'Titular de RFCs, crea carteras y gestiona auxiliares',
},
{
value: 'auxiliar',
label: 'Auxiliar',
description: 'Accede a RFCs asignados vía carteras',
},
{
value: 'cliente',
label: 'Cliente',
description: 'Visor externo — acceso read-only a sus RFCs',
},
];
// ── Fallback for unknown roles ─────────────────────────────────────────────────
function getRoleInfo(
role: string,
isDespacho: boolean,
): { label: string; icon: React.ElementType; color: string } {
if (isDespacho) {
return despachoRoleLabels[role] ?? { label: role, icon: Eye, color: 'text-muted-foreground' };
}
return legacyRoleLabels[role] ?? { label: role, icon: Eye, color: 'text-muted-foreground' };
}
export default function UsuariosPage() {
const { user: currentUser } = useAuthStore();
const { data: usuarios, isLoading } = useUsuarios();
const { data: contribuyentes } = useContribuyentes();
const inviteUsuario = useInviteUsuario();
const updateUsuario = useUpdateUsuario();
const deleteUsuario = useDeleteUsuario();
const isDespacho = isDespachoTenant(currentUser?.tenantRfc);
const inviteRoles = isDespacho ? despachoInviteRoles : legacyInviteRoles;
const defaultInviteRole = isDespacho ? 'auxiliar' : 'visor';
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 }>({
email: '',
nombre: '',
role: defaultInviteRole as Role,
});
const [selectedRfcIds, setSelectedRfcIds] = useState<string[]>([]);
// Edit accesos modal
const [editingAccesosUser, setEditingAccesosUser] = useState<{ id: string; nombre: string } | null>(null);
const [accesosRfcIds, setAccesosRfcIds] = useState<string[]>([]);
const [savingAccesos, setSavingAccesos] = useState(false);
// Edit supervisor modal (para auxiliares)
const [editingSupervisorUser, setEditingSupervisorUser] = useState<{ id: string; nombre: string } | null>(null);
const [selectedSupervisorId, setSelectedSupervisorId] = useState<string>('');
const [savingSupervisor, setSavingSupervisor] = useState(false);
const openEditSupervisor = async (userId: string, nombre: string) => {
try {
const res = await apiClient.get<{ supervisorUserId: string | null }>(`/usuarios/${userId}/supervisor`);
setSelectedSupervisorId(res.data.supervisorUserId ?? '');
setEditingSupervisorUser({ id: userId, nombre });
} catch {
alert('Error al cargar supervisor');
}
};
const handleSaveSupervisor = async () => {
if (!editingSupervisorUser) return;
setSavingSupervisor(true);
try {
await apiClient.put(`/usuarios/${editingSupervisorUser.id}/supervisor`, {
supervisorUserId: selectedSupervisorId || null,
});
setEditingSupervisorUser(null);
} catch {
alert('Error al guardar supervisor');
} finally {
setSavingSupervisor(false);
}
};
const openEditAccesos = async (userId: string, nombre: string) => {
try {
const res = await apiClient.get<{ data: string[] }>(`/usuarios/${userId}/accesos`);
setAccesosRfcIds(res.data.data);
setEditingAccesosUser({ id: userId, nombre });
} catch {
alert('Error al cargar accesos');
}
};
const handleSaveAccesos = async () => {
if (!editingAccesosUser) return;
setSavingAccesos(true);
try {
await apiClient.post(`/usuarios/${editingAccesosUser.id}/accesos`, { entidadIds: accesosRfcIds });
setEditingAccesosUser(null);
} catch {
alert('Error al guardar accesos');
} finally {
setSavingAccesos(false);
}
};
const toggleAccesoRfc = (id: string) => {
setAccesosRfcIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
};
// Fetch supervisors for auxiliar invite dropdown
const { data: supervisores } = useQuery({
queryKey: ['cartera-supervisores'],
queryFn: async () => {
const res = await apiClient.get<{ data: Array<{ userId: string; nombre: string; email: string }> }>('/carteras/supervisores');
return res.data.data;
},
enabled: isDespacho && isAdmin,
});
const toggleRfc = (id: string) => {
setSelectedRfcIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
};
const handleInvite = async (e: React.FormEvent) => {
e.preventDefault();
if (inviteForm.role === 'auxiliar' && !inviteForm.supervisorUserId) {
alert('Debes asignar un supervisor al auxiliar');
return;
}
try {
const newUser = await inviteUsuario.mutateAsync(inviteForm);
// If role is 'cliente' and RFCs were selected, grant access to each
if (inviteForm.role === 'cliente' && selectedRfcIds.length > 0) {
await Promise.all(
selectedRfcIds.map((rfcId) => addClienteAcceso(rfcId, newUser.id)),
);
}
setShowInvite(false);
setInviteForm({ email: '', nombre: '', role: defaultInviteRole as Role, supervisorUserId: undefined });
setSelectedRfcIds([]);
} catch (error: any) {
alert(error.response?.data?.message || 'Error al invitar usuario');
}
};
const handleToggleActive = (id: string, active: boolean) => {
updateUsuario.mutate({ id, data: { active: !active } });
};
const handleDelete = (id: string) => {
if (confirm('¿Eliminar este usuario?')) {
deleteUsuario.mutate(id);
}
};
return (
<DashboardShell title="Usuarios">
<div className="space-y-4">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold">Usuarios</h1>
<p className="text-sm text-muted-foreground">Gestiona tu equipo</p>
</div>
<div className="flex items-center gap-2">
{isAdmin && isDespacho && (
<Link href="/carteras">
<Button variant="outline" className="flex items-center gap-2">
<FolderOpen className="h-4 w-4" /> Gestionar Carteras
</Button>
</Link>
)}
{isAdmin && (
<Button onClick={() => setShowInvite(true)}>
<UserPlus className="h-4 w-4 mr-2" />
Invitar Usuario
</Button>
)}
</div>
</div>
{/* User count */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Users className="h-4 w-4" />
<span>{usuarios?.length || 0} usuarios</span>
</div>
{/* Invite Form */}
{showInvite && (
<Card>
<CardHeader>
<CardTitle>Invitar Nuevo Usuario</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleInvite} className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={inviteForm.email}
onChange={e => setInviteForm({ ...inviteForm, email: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="nombre">Nombre</Label>
<Input
id="nombre"
value={inviteForm.nombre}
onChange={e => setInviteForm({ ...inviteForm, nombre: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="role">Rol</Label>
<Select
value={inviteForm.role}
onValueChange={(v) => { setInviteForm({ ...inviteForm, role: v as Role, supervisorUserId: undefined }); if (v !== 'cliente') setSelectedRfcIds([]); }}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{inviteRoles.map(r => (
<SelectItem key={r.value} value={r.value}>
<div className="flex flex-col">
<span>{r.label}</span>
{'description' in r && r.description && (
<span className="text-xs text-muted-foreground">{r.description}</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Supervisor selector for auxiliar role */}
{isDespacho && inviteForm.role === 'auxiliar' && (
<div className="space-y-2">
<Label>Supervisor asignado</Label>
{supervisores && supervisores.length > 0 ? (
<Select
value={inviteForm.supervisorUserId || ''}
onValueChange={(v) => setInviteForm({ ...inviteForm, supervisorUserId: v })}
>
<SelectTrigger>
<SelectValue placeholder="Seleccionar supervisor..." />
</SelectTrigger>
<SelectContent>
{supervisores.map(s => (
<SelectItem key={s.userId} value={s.userId}>
{s.nombre} ({s.email})
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-sm text-muted-foreground border rounded-md p-3">
No hay supervisores registrados. Crea uno primero para poder asignar auxiliares.
</p>
)}
</div>
)}
{/* RFC access for cliente role */}
{isDespacho && inviteForm.role === 'cliente' && contribuyentes && contribuyentes.length > 0 && (
<div className="space-y-2">
<Label>RFCs con acceso (opcional)</Label>
<div className="border rounded-md p-3 space-y-2 max-h-48 overflow-y-auto">
{contribuyentes.map((c) => (
<div key={c.id} className="flex items-center gap-2">
<input
type="checkbox"
id={`rfc-${c.id}`}
checked={selectedRfcIds.includes(c.id)}
onChange={() => toggleRfc(c.id)}
className="h-4 w-4"
/>
<label htmlFor={`rfc-${c.id}`} className="text-sm cursor-pointer">
<span className="font-mono">{c.rfc}</span>
{' — '}
<span className="text-muted-foreground">{c.nombre}</span>
</label>
</div>
))}
</div>
</div>
)}
<div className="flex gap-2">
<Button type="submit" disabled={inviteUsuario.isPending}>
{inviteUsuario.isPending ? 'Enviando...' : 'Enviar Invitación'}
</Button>
<Button type="button" variant="outline" onClick={() => { setShowInvite(false); setSelectedRfcIds([]); }}>
Cancelar
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{/* Users List */}
<Card>
<CardContent className="p-0">
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : (
<div className="divide-y">
{usuarios?.map(usuario => {
const roleInfo = getRoleInfo(usuario.role, isDespacho);
const RoleIcon = roleInfo.icon;
const isCurrentUser = usuario.id === currentUser?.id;
return (
<div key={usuario.id} className="p-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={cn(
'w-10 h-10 rounded-full flex items-center justify-center',
'bg-primary/10 text-primary font-medium'
)}>
{usuario.nombre.charAt(0).toUpperCase()}
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{usuario.nombre}</span>
{isCurrentUser && (
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded"></span>
)}
{!usuario.active && (
<span className="text-xs bg-destructive/10 text-destructive px-2 py-0.5 rounded">Inactivo</span>
)}
</div>
<div className="text-sm text-muted-foreground">{usuario.email}</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className={cn('flex items-center gap-1', roleInfo.color)}>
<RoleIcon className="h-4 w-4" />
<span className="text-sm">{roleInfo.label}</span>
</div>
{isAdmin && !isCurrentUser && (
<div className="flex gap-1">
{isDespacho && usuario.role === 'cliente' && (
<Button
variant="ghost"
size="sm"
onClick={() => openEditAccesos(usuario.id, usuario.nombre)}
title="Editar RFCs con acceso"
>
<KeyRound className="h-4 w-4 mr-1" /> Accesos
</Button>
)}
{isDespacho && usuario.role === 'auxiliar' && (
<Button
variant="ghost"
size="sm"
onClick={() => openEditSupervisor(usuario.id, usuario.nombre)}
title="Asignar supervisor"
>
<UserCheck className="h-4 w-4 mr-1" /> Supervisor
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => handleToggleActive(usuario.id, usuario.active)}
>
{usuario.active ? 'Desactivar' : 'Activar'}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(usuario.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
{/* Edit Accesos Modal */}
{editingAccesosUser && (
<Dialog open onOpenChange={(open) => { if (!open) setEditingAccesosUser(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Editar accesos {editingAccesosUser.nombre}</DialogTitle>
</DialogHeader>
<div className="space-y-2 max-h-60 overflow-y-auto py-2">
{contribuyentes && contribuyentes.length > 0 ? (
contribuyentes.map((c) => (
<div key={c.id} className="flex items-center gap-2">
<input
type="checkbox"
id={`acceso-${c.id}`}
checked={accesosRfcIds.includes(c.id)}
onChange={() => toggleAccesoRfc(c.id)}
className="h-4 w-4"
/>
<label htmlFor={`acceso-${c.id}`} className="text-sm cursor-pointer">
<span className="font-mono">{c.rfc}</span>
{' — '}
<span className="text-muted-foreground">{c.nombre}</span>
</label>
</div>
))
) : (
<p className="text-sm text-muted-foreground">No hay contribuyentes registrados.</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditingAccesosUser(null)}>Cancelar</Button>
<Button onClick={handleSaveAccesos} disabled={savingAccesos}>
{savingAccesos ? 'Guardando...' : 'Guardar'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{/* Edit Supervisor Modal (auxiliares) */}
{editingSupervisorUser && (
<Dialog open onOpenChange={(open) => { if (!open) setEditingSupervisorUser(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Asignar supervisor {editingSupervisorUser.nombre}</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
{supervisores && supervisores.length > 0 ? (
<Select value={selectedSupervisorId || 'none'} onValueChange={(v) => setSelectedSupervisorId(v === 'none' ? '' : v)}>
<SelectTrigger><SelectValue placeholder="Selecciona un supervisor..." /></SelectTrigger>
<SelectContent>
<SelectItem value="none">Sin supervisor asignado</SelectItem>
{supervisores.map(s => (
<SelectItem key={s.userId} value={s.userId}>
{s.nombre} {s.email}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-sm text-muted-foreground">No hay supervisores registrados todavía.</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditingSupervisorUser(null)}>Cancelar</Button>
<Button onClick={handleSaveSupervisor} disabled={savingSupervisor}>
{savingSupervisor ? 'Guardando...' : 'Guardar'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</DashboardShell>
);
}

63
apps/web/app/globals.css Normal file
View File

@@ -0,0 +1,63 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--success: 142.1 76.2% 36.3%;
--success-foreground: 355.7 100% 97.3%;
--warning: 38 92% 50%;
--warning-foreground: 0 0% 100%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--primary: 187.2 85.7% 53.3%;
--primary-foreground: 222.2 84% 4.9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--success: 142.1 70.6% 45.3%;
--success-foreground: 144.9 80.4% 10%;
--warning: 38 92% 50%;
--warning-foreground: 0 0% 100%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 187.2 85.7% 53.3%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

28
apps/web/app/layout.tsx Normal file
View File

@@ -0,0 +1,28 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { ThemeProvider } from '@/components/providers/theme-provider';
import { QueryProvider } from '@/components/providers/query-provider';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'Horux360 - Análisis Financiero',
description: 'Plataforma de análisis financiero y gestión fiscal para empresas mexicanas',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="es" suppressHydrationWarning>
<body className={inter.className}>
<QueryProvider>
<ThemeProvider>{children}</ThemeProvider>
</QueryProvider>
</body>
</html>
);
}

5
apps/web/app/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Home() {
redirect('/dashboard');
}

View File

@@ -0,0 +1,82 @@
import { Download, ArrowLeft, ExternalLink } from 'lucide-react';
import Link from 'next/link';
export const metadata = {
title: 'Términos y Condiciones — Horux 360',
};
const PDF_URL = '/legal/terminos-y-condiciones.pdf';
export default function TerminosPage() {
return (
<div className="min-h-screen flex flex-col bg-background">
<header className="sticky top-0 z-10 border-b bg-background/95 backdrop-blur">
<div className="mx-auto max-w-6xl px-6 py-3 flex items-center justify-between gap-4">
<Link href="/login" className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="h-4 w-4" />
Volver
</Link>
<h1 className="text-base sm:text-lg font-semibold truncate">Términos y Condiciones</h1>
<div className="flex items-center gap-3">
<a
href={PDF_URL}
target="_blank"
rel="noopener noreferrer"
className="hidden sm:flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
title="Abrir en nueva pestaña"
>
<ExternalLink className="h-4 w-4" />
Abrir
</a>
<a
href={PDF_URL}
download
className="flex items-center gap-2 text-sm font-medium text-primary hover:underline"
>
<Download className="h-4 w-4" />
Descargar
</a>
</div>
</div>
</header>
{/* El PDF se renderiza con el viewer nativo del navegador — preserva
fuentes, tablas, numeración, listas, todo el formato original. El
fallback para móviles que no soportan inline PDF aparece abajo. */}
<main className="flex-1 mx-auto w-full max-w-6xl px-2 sm:px-6 py-4">
<object
data={PDF_URL}
type="application/pdf"
className="w-full h-[calc(100vh-120px)] min-h-[600px] rounded border"
aria-label="Términos y Condiciones de Horux 360"
>
{/* Fallback: navegadores/móviles sin viewer inline */}
<div className="p-8 text-center space-y-4">
<p className="text-muted-foreground">
Tu navegador no puede mostrar el PDF directamente.
</p>
<div className="flex flex-wrap gap-3 justify-center">
<a
href={PDF_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 rounded bg-primary text-primary-foreground font-medium hover:bg-primary/90"
>
<ExternalLink className="h-4 w-4" />
Abrir en nueva pestaña
</a>
<a
href={PDF_URL}
download
className="inline-flex items-center gap-2 px-4 py-2 rounded border hover:bg-accent"
>
<Download className="h-4 w-4" />
Descargar PDF
</a>
</div>
</div>
</object>
</main>
</div>
);
}

View File

@@ -0,0 +1,324 @@
'use client';
import { forwardRef } from 'react';
import type { Cfdi } from '@horux/shared';
interface CfdiConcepto {
descripcion: string;
cantidad: number;
valorUnitario: number;
importe: number;
claveUnidad?: string;
claveProdServ?: string;
}
interface CfdiInvoiceProps {
cfdi: Cfdi;
conceptos?: CfdiConcepto[];
}
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
}).format(value);
const formatDate = (dateString: string) =>
new Date(dateString).toLocaleDateString('es-MX', {
day: '2-digit',
month: 'long',
year: 'numeric',
});
const formatDateTime = (dateString: string) =>
new Date(dateString).toLocaleString('es-MX', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
const typeLabels: Record<string, string> = {
EMITIDO: 'Emitido',
RECIBIDO: 'Recibido',
};
const tipoCompLabels: Record<string, string> = {
I: 'Ingreso',
E: 'Egreso',
T: 'Traslado',
P: 'Pago',
N: 'Nómina',
};
const formaPagoLabels: Record<string, string> = {
'01': 'Efectivo',
'02': 'Cheque nominativo',
'03': 'Transferencia electrónica',
'04': 'Tarjeta de crédito',
'28': 'Tarjeta de débito',
'99': 'Por definir',
};
const metodoPagoLabels: Record<string, string> = {
PUE: 'Pago en una sola exhibición',
PPD: 'Pago en parcialidades o diferido',
};
const usoCfdiLabels: Record<string, string> = {
G01: 'Adquisición de mercancías',
G02: 'Devoluciones, descuentos o bonificaciones',
G03: 'Gastos en general',
I01: 'Construcciones',
I02: 'Mobilario y equipo de oficina',
I03: 'Equipo de transporte',
I04: 'Equipo de cómputo',
I05: 'Dados, troqueles, moldes',
I06: 'Comunicaciones telefónicas',
I07: 'Comunicaciones satelitales',
I08: 'Otra maquinaria y equipo',
D01: 'Honorarios médicos',
D02: 'Gastos médicos por incapacidad',
D03: 'Gastos funerales',
D04: 'Donativos',
D05: 'Intereses por créditos hipotecarios',
D06: 'Aportaciones voluntarias SAR',
D07: 'Primas por seguros de gastos médicos',
D08: 'Gastos de transportación escolar',
D09: 'Depósitos en cuentas para el ahorro',
D10: 'Pagos por servicios educativos',
P01: 'Por definir',
S01: 'Sin efectos fiscales',
CP01: 'Pagos',
CN01: 'Nómina',
};
export const CfdiInvoice = forwardRef<HTMLDivElement, CfdiInvoiceProps>(
({ cfdi, conceptos }, ref) => {
return (
<div
ref={ref}
className="bg-white text-gray-800 max-w-[850px] mx-auto text-sm shadow-lg"
style={{ fontFamily: 'Segoe UI, Roboto, Arial, sans-serif' }}
>
{/* Header con gradiente */}
<div className="bg-gradient-to-r from-blue-700 to-blue-900 text-white p-6">
<div className="flex justify-between items-start">
<div>
<h2 className="text-lg font-semibold opacity-90">Emisor</h2>
<p className="text-xl font-bold mt-1">{cfdi.nombreEmisor}</p>
<p className="text-blue-200 text-sm mt-1">RFC: {cfdi.rfcEmisor}</p>
</div>
<div className="text-right">
<div className="flex items-center justify-end gap-3 mb-2">
<span
className={`px-3 py-1 text-xs font-bold rounded-full ${
cfdi.status === 'Vigente' || cfdi.status === '1'
? 'bg-green-400 text-green-900'
: 'bg-red-400 text-red-900'
}`}
>
{cfdi.status === 'Vigente' || cfdi.status === '1' ? 'VIGENTE' : 'CANCELADO'}
</span>
<span className="px-3 py-1 text-xs font-bold rounded-full bg-white/20">
{typeLabels[cfdi.type] || cfdi.type} {cfdi.tipoComprobante ? `(${tipoCompLabels[cfdi.tipoComprobante] || cfdi.tipoComprobante})` : ''}
</span>
</div>
<div className="text-3xl font-bold tracking-tight">
{cfdi.serie && <span className="text-blue-300">{cfdi.serie}-</span>}
{cfdi.folio || 'S/N'}
</div>
<p className="text-blue-200 text-sm mt-1">{formatDate(cfdi.fechaEmision)}</p>
</div>
</div>
</div>
<div className="p-6">
{/* Receptor */}
<div className="bg-gray-50 rounded-lg p-4 mb-5 border-l-4 border-blue-600">
<div className="flex items-start justify-between">
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide font-medium">Receptor</p>
<p className="text-lg font-semibold text-gray-800 mt-1">{cfdi.nombreReceptor}</p>
<p className="text-gray-600 text-sm">RFC: {cfdi.rfcReceptor}</p>
</div>
{cfdi.usoCfdi && (
<div className="text-right">
<p className="text-xs text-gray-500 uppercase tracking-wide font-medium">Uso CFDI</p>
<p className="text-sm font-medium text-gray-700 mt-1">
{cfdi.usoCfdi} - {usoCfdiLabels[cfdi.usoCfdi] || ''}
</p>
</div>
)}
</div>
</div>
{/* Datos del Comprobante */}
<div className="grid grid-cols-4 gap-3 mb-5">
<div className="bg-gray-50 rounded-lg p-3 text-center">
<p className="text-xs text-gray-500 uppercase tracking-wide">Método Pago</p>
<p className="text-sm font-semibold text-gray-800 mt-1">
{cfdi.metodoPago || '-'}
</p>
<p className="text-xs text-gray-500">
{cfdi.metodoPago ? metodoPagoLabels[cfdi.metodoPago] || '' : ''}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center">
<p className="text-xs text-gray-500 uppercase tracking-wide">Forma Pago</p>
<p className="text-sm font-semibold text-gray-800 mt-1">
{cfdi.formaPago || '-'}
</p>
<p className="text-xs text-gray-500">
{cfdi.formaPago ? formaPagoLabels[cfdi.formaPago] || '' : ''}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center">
<p className="text-xs text-gray-500 uppercase tracking-wide">Moneda</p>
<p className="text-sm font-semibold text-gray-800 mt-1">{cfdi.moneda || 'MXN'}</p>
{cfdi.typeCambio && cfdi.typeCambio !== 1 && (
<p className="text-xs text-gray-500">TC: {cfdi.typeCambio}</p>
)}
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center">
<p className="text-xs text-gray-500 uppercase tracking-wide">Versión</p>
<p className="text-sm font-semibold text-gray-800 mt-1">CFDI 4.0</p>
</div>
</div>
{/* Conceptos */}
{conceptos && conceptos.length > 0 && (
<div className="mb-5">
<h3 className="text-xs text-gray-500 uppercase tracking-wide font-medium mb-2 flex items-center gap-2">
<span className="w-1 h-4 bg-blue-600 rounded-full"></span>
Conceptos
</h3>
<div className="border border-gray-200 rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-100">
<th className="text-left py-3 px-4 font-semibold text-gray-700">Descripción</th>
<th className="text-center py-3 px-3 font-semibold text-gray-700 w-20">Cant.</th>
<th className="text-right py-3 px-4 font-semibold text-gray-700 w-32">P. Unitario</th>
<th className="text-right py-3 px-4 font-semibold text-gray-700 w-32">Importe</th>
</tr>
</thead>
<tbody>
{conceptos.map((concepto, idx) => (
<tr
key={idx}
className={`border-t border-gray-100 ${idx % 2 === 1 ? 'bg-gray-50/50' : ''}`}
>
<td className="py-3 px-4">
<p className="text-gray-800">{concepto.descripcion}</p>
{concepto.claveProdServ && (
<p className="text-xs text-gray-400 mt-0.5">
Clave: {concepto.claveProdServ}
{concepto.claveUnidad && ` | Unidad: ${concepto.claveUnidad}`}
</p>
)}
</td>
<td className="text-center py-3 px-3 text-gray-700">{concepto.cantidad}</td>
<td className="text-right py-3 px-4 text-gray-700">
{formatCurrency(concepto.valorUnitario)}
</td>
<td className="text-right py-3 px-4 font-medium text-gray-800">
{formatCurrency(concepto.importe)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Totales */}
<div className="flex justify-end mb-5">
<div className="w-80 bg-gray-50 rounded-lg overflow-hidden">
<div className="divide-y divide-gray-200">
<div className="flex justify-between py-2.5 px-4">
<span className="text-gray-600">Subtotal</span>
<span className="font-medium">{formatCurrency(cfdi.subtotal)}</span>
</div>
{cfdi.descuento > 0 && (
<div className="flex justify-between py-2.5 px-4">
<span className="text-gray-600">Descuento</span>
<span className="font-medium text-red-600">-{formatCurrency(cfdi.descuento)}</span>
</div>
)}
{cfdi.ivaTraslado > 0 && (
<div className="flex justify-between py-2.5 px-4">
<span className="text-gray-600">IVA (16%)</span>
<span className="font-medium">{formatCurrency(cfdi.ivaTraslado)}</span>
</div>
)}
{cfdi.ivaRetencion > 0 && (
<div className="flex justify-between py-2.5 px-4">
<span className="text-gray-600">IVA Retenido</span>
<span className="font-medium text-red-600">-{formatCurrency(cfdi.ivaRetencion)}</span>
</div>
)}
{cfdi.isrRetencion > 0 && (
<div className="flex justify-between py-2.5 px-4">
<span className="text-gray-600">ISR Retenido</span>
<span className="font-medium text-red-600">-{formatCurrency(cfdi.isrRetencion)}</span>
</div>
)}
</div>
<div className="bg-blue-700 text-white py-3 px-4 flex justify-between items-center">
<span className="font-semibold">TOTAL</span>
<span className="text-xl font-bold">{formatCurrency(cfdi.total)}</span>
</div>
</div>
</div>
{/* Timbre Fiscal Digital */}
<div className="bg-gradient-to-r from-gray-100 to-gray-50 rounded-lg p-4 border border-gray-200">
<div className="flex gap-4">
{/* QR Placeholder */}
<div className="w-24 h-24 bg-white border-2 border-gray-300 rounded-lg flex items-center justify-center flex-shrink-0">
<div className="text-center">
<svg className="w-12 h-12 text-gray-400 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h2M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
</svg>
<span className="text-[10px] text-gray-400 mt-1 block">QR</span>
</div>
</div>
{/* Info del Timbre */}
<div className="flex-1 min-w-0">
<h3 className="text-xs text-gray-500 uppercase tracking-wide font-semibold mb-2 flex items-center gap-2">
<svg className="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
Timbre Fiscal Digital
</h3>
<div className="space-y-1.5">
<div>
<span className="text-xs text-gray-500">UUID: </span>
<span className="text-xs font-mono text-blue-700 font-medium">{cfdi.uuid}</span>
</div>
<div>
{cfdi.fechaCertSat && (<>
<span className="text-xs text-gray-500">Fecha Certificación SAT: </span>
<span className="text-xs font-medium text-gray-700">{formatDateTime(cfdi.fechaCertSat)}</span>
</>)}
</div>
</div>
</div>
</div>
{/* Leyenda */}
<p className="text-[10px] text-gray-400 mt-3 text-center border-t border-gray-200 pt-2">
Este documento es una representación impresa de un CFDI Verificable en: https://verificacfdi.facturaelectronica.sat.gob.mx
</p>
</div>
</div>
</div>
);
}
);
CfdiInvoice.displayName = 'CfdiInvoice';

View File

@@ -0,0 +1,244 @@
'use client';
import { useRef, useState, useEffect } from 'react';
import type { Cfdi } from '@horux/shared';
import { Dialog, DialogContent, DialogHeader, DialogTitle, Button } from '@horux/shared-ui';
import { CfdiInvoice } from './cfdi-invoice';
import { getCfdiXml, getCfdiConceptos } from '@/lib/api/cfdi';
import { Download, FileText, Loader2, Printer } from 'lucide-react';
interface CfdiConcepto {
descripcion: string;
cantidad: number;
valorUnitario: number;
importe: number;
claveProdServ?: string;
claveUnidad?: string;
}
interface CfdiViewerModalProps {
cfdi: Cfdi | null;
open: boolean;
onClose: () => void;
}
function parseConceptosFromXml(xmlString: string): CfdiConcepto[] {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(xmlString, 'text/xml');
const conceptos: CfdiConcepto[] = [];
const elements = doc.getElementsByTagName('*');
for (let i = 0; i < elements.length; i++) {
if (elements[i].localName === 'Concepto') {
const el = elements[i];
conceptos.push({
descripcion: el.getAttribute('Descripcion') || '',
cantidad: parseFloat(el.getAttribute('Cantidad') || '1'),
valorUnitario: parseFloat(el.getAttribute('ValorUnitario') || '0'),
importe: parseFloat(el.getAttribute('Importe') || '0'),
claveProdServ: el.getAttribute('ClaveProdServ') || undefined,
claveUnidad: el.getAttribute('ClaveUnidad') || undefined,
});
}
}
return conceptos;
} catch {
return [];
}
}
export function CfdiViewerModal({ cfdi, open, onClose }: CfdiViewerModalProps) {
const invoiceRef = useRef<HTMLDivElement>(null);
const [conceptos, setConceptos] = useState<CfdiConcepto[]>([]);
const [downloading, setDownloading] = useState<'pdf' | 'xml' | null>(null);
const [xmlContent, setXmlContent] = useState<string | null>(null);
useEffect(() => {
if (!cfdi) {
setXmlContent(null);
setConceptos([]);
return;
}
if (cfdi.xmlOriginal) setXmlContent(cfdi.xmlOriginal);
// Fetch conceptos from DB, fallback to XML parsing
getCfdiConceptos(cfdi.id)
.then((dbConceptos) => {
if (dbConceptos.length > 0) {
setConceptos(dbConceptos.map((c: any) => ({
descripcion: c.descripcion,
cantidad: Number(c.cantidad),
valorUnitario: Number(c.valorUnitario),
importe: Number(c.importe),
claveProdServ: c.claveProdServ || undefined,
claveUnidad: c.claveUnidad || undefined,
})));
} else if (cfdi.xmlOriginal) {
setConceptos(parseConceptosFromXml(cfdi.xmlOriginal));
} else {
setConceptos([]);
}
})
.catch(() => {
if (cfdi.xmlOriginal) {
setConceptos(parseConceptosFromXml(cfdi.xmlOriginal));
}
});
}, [cfdi]);
const handleDownloadPdf = async () => {
if (!invoiceRef.current || !cfdi) return;
setDownloading('pdf');
try {
const html2pdf = (await import('html2pdf.js')).default;
const opt = {
margin: 10,
filename: `factura-${cfdi.uuid.substring(0, 8)}.pdf`,
image: { type: 'jpeg' as const, quality: 0.98 },
html2canvas: { scale: 2, useCORS: true },
jsPDF: { unit: 'mm' as const, format: 'a4' as const, orientation: 'portrait' as const },
};
await html2pdf().set(opt).from(invoiceRef.current).save();
} catch (error) {
console.error('Error generating PDF:', error);
alert('Error al generar el PDF');
} finally {
setDownloading(null);
}
};
const handleDownloadXml = async () => {
if (!cfdi) return;
setDownloading('xml');
try {
let xml = xmlContent;
if (!xml) {
xml = await getCfdiXml(cfdi.id);
}
if (!xml) {
alert('No hay XML disponible para este CFDI');
return;
}
const blob = new Blob([xml], { type: 'application/xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `cfdi-${cfdi.uuid.substring(0, 8)}.xml`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Error downloading XML:', error);
alert('Error al descargar el XML');
} finally {
setDownloading(null);
}
};
const handlePrint = () => {
if (!invoiceRef.current) return;
// Create a print-specific stylesheet
const printStyles = document.createElement('style');
printStyles.innerHTML = `
@media print {
body * {
visibility: hidden;
}
#cfdi-print-area, #cfdi-print-area * {
visibility: visible;
}
#cfdi-print-area {
position: absolute;
left: 0;
top: 0;
width: 100%;
padding: 20px;
}
@page {
size: A4;
margin: 15mm;
}
}
`;
document.head.appendChild(printStyles);
// Add ID to the invoice container for print targeting
invoiceRef.current.id = 'cfdi-print-area';
// Trigger print
window.print();
// Clean up
document.head.removeChild(printStyles);
invoiceRef.current.removeAttribute('id');
};
if (!cfdi) return null;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center justify-between">
<DialogTitle>Vista de Factura</DialogTitle>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleDownloadPdf}
disabled={downloading !== null}
>
{downloading === 'pdf' ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Download className="h-4 w-4 mr-1" />
)}
PDF
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDownloadXml}
disabled={downloading !== null || !xmlContent}
title={!xmlContent ? 'XML no disponible' : 'Descargar XML'}
>
{downloading === 'xml' ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<FileText className="h-4 w-4 mr-1" />
)}
XML
</Button>
<Button
variant="outline"
size="sm"
onClick={handlePrint}
disabled={downloading !== null}
title="Imprimir factura"
>
<Printer className="h-4 w-4 mr-1" />
Imprimir
</Button>
</div>
</div>
</DialogHeader>
<div className="border rounded-lg overflow-hidden bg-gray-50 p-4">
<CfdiInvoice ref={invoiceRef} cfdi={cfdi} conceptos={conceptos} />
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,87 @@
'use client';
import {
BarChart as RechartsBarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from 'recharts';
import { Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
interface BarChartProps {
title: string;
data: { mes: string; ingresos: number; egresos: number }[];
}
const formatCurrency = (value: number) => {
if (value >= 1000000) {
return `$${(value / 1000000).toFixed(1)}M`;
}
if (value >= 1000) {
return `$${(value / 1000).toFixed(0)}K`;
}
return `$${value}`;
};
export function BarChart({ title, data }: BarChartProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base font-medium">{title}</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<RechartsBarChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="mes"
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
className="text-muted-foreground"
/>
<YAxis
tickFormatter={formatCurrency}
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
className="text-muted-foreground"
/>
<Tooltip
formatter={(value: number) =>
new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
}).format(value)
}
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '8px',
}}
/>
<Legend />
<Bar
dataKey="ingresos"
name="Ingresos"
fill="hsl(var(--success))"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="egresos"
name="Egresos"
fill="hsl(var(--destructive))"
radius={[4, 4, 0, 0]}
/>
</RechartsBarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1 @@
export { BarChart } from './bar-chart';

View File

@@ -0,0 +1,108 @@
'use client';
import { useState, useEffect } from 'react';
import { usePathname } from 'next/navigation';
import { useContribuyentes } from '@/lib/hooks/use-contribuyentes';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { cn } from '@horux/shared-ui';
import { Building2, ChevronDown, Check, Users } from 'lucide-react';
// Rutas donde el selector NO aplica (vistas cross-contribuyente del despacho).
const HIDDEN_PATHS = ['/despachos'];
export function ContribuyenteSelector() {
const pathname = usePathname();
const [open, setOpen] = useState(false);
const { data: contribuyentes, isLoading } = useContribuyentes();
const { selectedContribuyenteId, setSelectedContribuyente, clearSelectedContribuyente } =
useContribuyenteStore();
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest('.contribuyente-selector')) setOpen(false);
};
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}, []);
// Auto-select if user has exactly 1 contribuyente (common for clients)
useEffect(() => {
if (contribuyentes && contribuyentes.length === 1 && !selectedContribuyenteId) {
setSelectedContribuyente(contribuyentes[0].id, contribuyentes[0].rfc, contribuyentes[0].nombre);
}
}, [contribuyentes, selectedContribuyenteId, setSelectedContribuyente]);
if (isLoading || !contribuyentes || contribuyentes.length === 0) return null;
if (pathname && HIDDEN_PATHS.some(p => pathname === p || pathname.startsWith(`${p}/`))) return null;
const selected = contribuyentes.find((c) => c.id === selectedContribuyenteId);
return (
<div className="contribuyente-selector relative">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium hover:bg-accent transition-colors"
>
<Building2 className="h-4 w-4" />
<span className="max-w-[180px] truncate">
{selected ? selected.nombre : 'Todos los RFCs'}
</span>
<ChevronDown className={cn('h-4 w-4 transition-transform', open && 'rotate-180')} />
</button>
{open && (
<div className="absolute top-full right-0 mt-2 w-80 rounded-lg border bg-card shadow-lg z-50">
<div className="p-2 border-b">
<p className="text-xs text-muted-foreground px-2">Contribuyentes</p>
</div>
<div className="max-h-80 overflow-y-auto p-1">
{/* Todos los RFCs — only show if more than 1 contribuyente */}
{contribuyentes.length > 1 && (
<>
<button
onClick={() => { clearSelectedContribuyente(); setOpen(false); }}
className={cn(
'flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors text-left',
!selectedContribuyenteId && 'bg-primary/10'
)}
>
<div className="h-8 w-8 rounded bg-muted flex items-center justify-center">
<Users className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium">Todos los RFCs</p>
<p className="text-xs text-muted-foreground">{contribuyentes.length} contribuyentes</p>
</div>
{!selectedContribuyenteId && <Check className="h-4 w-4 text-primary flex-shrink-0" />}
</button>
<div className="border-t my-1" />
</>
)}
{/* Lista de contribuyentes */}
{contribuyentes.map((c) => (
<button
key={c.id}
onClick={() => { setSelectedContribuyente(c.id, c.rfc, c.nombre); setOpen(false); }}
className={cn(
'flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors text-left',
selectedContribuyenteId === c.id && 'bg-primary/10'
)}
>
<div className="h-8 w-8 rounded bg-muted flex items-center justify-center text-xs font-medium">
{c.nombre.substring(0, 2).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{c.nombre}</p>
<p className="text-xs text-muted-foreground truncate">{c.rfc}</p>
</div>
{selectedContribuyenteId === c.id && <Check className="h-4 w-4 text-primary flex-shrink-0" />}
</button>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,57 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useAuthStore } from '@/stores/auth-store';
import { cn } from '@horux/shared-ui';
import { Building2, UserCheck, Users } from 'lucide-react';
interface NavItem {
href: string;
label: string;
icon: React.ElementType;
roles: string[];
}
const ITEMS: NavItem[] = [
{ href: '/despachos/contribuyentes', label: 'Contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
{ href: '/despachos/mis-asignados', label: 'Mis asignados', icon: UserCheck, roles: ['owner', 'cfo', 'supervisor', 'auxiliar'] },
{ href: '/despachos/equipo', label: 'Equipo', icon: Users, roles: ['owner', 'cfo', 'supervisor'] },
];
export function DespachoSubnav() {
const pathname = usePathname();
const role = useAuthStore(s => s.user?.role);
if (!role) return null;
const visibles = ITEMS.filter(i => i.roles.includes(role));
return (
<div className="flex border-b mb-6">
{visibles.map(item => {
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
active
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground',
)}
>
<Icon className="h-4 w-4" />
{item.label}
</Link>
);
})}
</div>
);
}
/** Resuelve la página default según rol al entrar a /despachos. */
export function defaultDespachoPathForRole(role: string): string {
if (role === 'owner' || role === 'cfo') return '/despachos/contribuyentes';
if (role === 'supervisor' || role === 'auxiliar') return '/despachos/mis-asignados';
return '/despachos/mis-asignados';
}

View File

@@ -0,0 +1,431 @@
'use client';
import { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Card, CardContent, Button, Input, Label,
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
cn,
} from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { useAuthStore } from '@/stores/auth-store';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { Upload, Download, Trash2, CheckCircle2, XCircle, Clock, AlertTriangle, MessageSquare } from 'lucide-react';
const MESES = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'];
const ALLOWED_MIMES = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
];
const ALLOWED_EXT = '.pdf,.doc,.docx,.xls,.xlsx';
const MAX_SIZE = 5 * 1024 * 1024;
const ROLES_APROBADOR = new Set(['owner', 'cfo', 'supervisor']);
interface Papeleria {
id: number;
contribuyenteId: string;
nombre: string;
descripcion: string | null;
archivoFilename: string;
archivoMime: string;
archivoSize: number;
anio: number;
mes: number;
requiereAprobacion: boolean;
estado: 'pendiente' | 'aprobado' | 'rechazado' | null;
comentarioRechazo: string | null;
subidoPor: string;
createdAt: string;
}
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => {
const result = String(r.result);
const i = result.indexOf(',');
resolve(i >= 0 ? result.substring(i + 1) : result);
};
r.onerror = reject;
r.readAsDataURL(file);
});
}
function EstadoBadge({ estado, requiereAprobacion }: { estado: string | null; requiereAprobacion: boolean }) {
if (!requiereAprobacion) {
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-muted text-muted-foreground">Sin aprobación</span>;
}
if (estado === 'aprobado') {
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"><CheckCircle2 className="h-3 w-3" /> Aprobado</span>;
}
if (estado === 'rechazado') {
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"><XCircle className="h-3 w-3" /> Rechazado</span>;
}
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"><Clock className="h-3 w-3" /> Pendiente</span>;
}
export function PapeleriaTab() {
const user = useAuthStore(s => s.user);
const { selectedContribuyenteId } = useContribuyenteStore();
const queryClient = useQueryClient();
const canApprove = user?.role ? ROLES_APROBADOR.has(user.role) : false;
const [showUpload, setShowUpload] = useState(false);
const [rechazoFor, setRechazoFor] = useState<Papeleria | null>(null);
const [comentarioRechazo, setComentarioRechazo] = useState('');
// Filtros
const currentYear = new Date().getFullYear();
const [filterAnio, setFilterAnio] = useState<number | ''>('');
const [filterMes, setFilterMes] = useState<number | ''>('');
const [filterEstado, setFilterEstado] = useState<string>('');
const query = useQuery<Papeleria[]>({
queryKey: ['papeleria', selectedContribuyenteId, filterAnio, filterMes, filterEstado],
queryFn: async () => {
const p = new URLSearchParams({ contribuyenteId: selectedContribuyenteId! });
if (filterAnio) p.set('anio', String(filterAnio));
if (filterMes) p.set('mes', String(filterMes));
if (filterEstado) p.set('estado', filterEstado);
const { data } = await apiClient.get<Papeleria[]>(`/papeleria?${p}`);
return data;
},
enabled: !!selectedContribuyenteId,
});
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['papeleria'] });
// Upload form state
const [file, setFile] = useState<File | null>(null);
const [nombre, setNombre] = useState('');
const [descripcion, setDescripcion] = useState('');
const [anio, setAnio] = useState(currentYear);
const [mes, setMes] = useState(new Date().getMonth() + 1);
const [requiereAprobacion, setRequiereAprobacion] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const resetUpload = () => {
setFile(null);
setNombre('');
setDescripcion('');
setAnio(currentYear);
setMes(new Date().getMonth() + 1);
setRequiereAprobacion(false);
setUploadError(null);
};
const uploadMutation = useMutation({
mutationFn: async () => {
if (!file) throw new Error('Selecciona un archivo');
if (!ALLOWED_MIMES.includes(file.type)) throw new Error('Formato no permitido. Usa PDF, Word o Excel.');
if (file.size > MAX_SIZE) throw new Error('El archivo excede 5 MB.');
const base64 = await fileToBase64(file);
await apiClient.post('/papeleria', {
contribuyenteId: selectedContribuyenteId,
nombre: nombre || file.name,
descripcion: descripcion || null,
anio,
mes,
requiereAprobacion,
archivoBase64: base64,
archivoFilename: file.name,
archivoMime: file.type,
});
},
onError: (err: any) => {
setUploadError(err?.response?.data?.message || err.message || 'Error al subir');
},
onSuccess: () => {
setShowUpload(false);
resetUpload();
invalidate();
},
});
const downloadMutation = useMutation({
mutationFn: async (item: Papeleria) => {
const res = await apiClient.get(`/papeleria/${item.id}/download`, { responseType: 'blob' });
const url = URL.createObjectURL(res.data);
const a = document.createElement('a');
a.href = url;
a.download = item.archivoFilename;
a.click();
URL.revokeObjectURL(url);
},
});
const aprobarMutation = useMutation({
mutationFn: async (id: number) => apiClient.post(`/papeleria/${id}/aprobar`),
onSuccess: invalidate,
});
const rechazarMutation = useMutation({
mutationFn: async ({ id, comentario }: { id: number; comentario: string | null }) =>
apiClient.post(`/papeleria/${id}/rechazar`, { comentario }),
onSuccess: () => {
setRechazoFor(null);
setComentarioRechazo('');
invalidate();
},
});
const eliminarMutation = useMutation({
mutationFn: async (id: number) => apiClient.delete(`/papeleria/${id}`),
onSuccess: invalidate,
});
if (!selectedContribuyenteId) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
Selecciona un contribuyente para ver su papelería.
</CardContent>
</Card>
);
}
const items = query.data ?? [];
const años = useMemo(() => {
const set = new Set<number>([currentYear]);
items.forEach(i => set.add(i.anio));
return [...set].sort((a, b) => b - a);
}, [items, currentYear]);
return (
<div className="space-y-4">
{/* Filtros + upload */}
<div className="flex items-end justify-between gap-3 flex-wrap">
<div className="flex items-end gap-2">
<div>
<Label className="text-xs">Año</Label>
<select
value={filterAnio}
onChange={e => setFilterAnio(e.target.value ? parseInt(e.target.value, 10) : '')}
className="h-9 rounded-md border bg-background px-2 text-sm"
>
<option value="">Todos</option>
{años.map(a => <option key={a} value={a}>{a}</option>)}
</select>
</div>
<div>
<Label className="text-xs">Mes</Label>
<select
value={filterMes}
onChange={e => setFilterMes(e.target.value ? parseInt(e.target.value, 10) : '')}
className="h-9 rounded-md border bg-background px-2 text-sm"
>
<option value="">Todos</option>
{MESES.map((m, i) => <option key={i + 1} value={i + 1}>{m}</option>)}
</select>
</div>
<div>
<Label className="text-xs">Estado</Label>
<select
value={filterEstado}
onChange={e => setFilterEstado(e.target.value)}
className="h-9 rounded-md border bg-background px-2 text-sm"
>
<option value="">Todos</option>
<option value="pendiente">Pendiente</option>
<option value="aprobado">Aprobado</option>
<option value="rechazado">Rechazado</option>
<option value="sin_aprobacion">Sin aprobación</option>
</select>
</div>
</div>
<Button onClick={() => setShowUpload(true)}>
<Upload className="h-4 w-4 mr-2" /> Subir documento
</Button>
</div>
{/* Listado */}
{query.isLoading ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
) : items.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No hay documentos en papelería con los filtros seleccionados.
</CardContent>
</Card>
) : (
<div className="space-y-2">
{items.map(it => (
<Card key={it.id}>
<CardContent className="py-3 flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{it.nombre}</span>
<EstadoBadge estado={it.estado} requiereAprobacion={it.requiereAprobacion} />
<span className="text-xs text-muted-foreground">
{MESES[it.mes - 1]} {it.anio}
</span>
</div>
{it.descripcion && (
<p className="text-xs text-muted-foreground mt-0.5">{it.descripcion}</p>
)}
<p className="text-xs text-muted-foreground mt-0.5">
{it.archivoFilename} · {(it.archivoSize / 1024).toFixed(0)} KB
· subido {new Date(it.createdAt).toLocaleDateString('es-MX')}
</p>
{it.estado === 'rechazado' && it.comentarioRechazo && (
<p className="text-xs mt-1 flex items-start gap-1 text-red-700 dark:text-red-400">
<MessageSquare className="h-3 w-3 mt-0.5 flex-shrink-0" />
<span>{it.comentarioRechazo}</span>
</p>
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<Button variant="ghost" size="icon" onClick={() => downloadMutation.mutate(it)} title="Descargar">
<Download className="h-4 w-4" />
</Button>
{canApprove && it.requiereAprobacion && it.estado === 'pendiente' && (
<>
<Button
variant="ghost" size="icon"
onClick={() => aprobarMutation.mutate(it.id)}
title="Aprobar"
>
<CheckCircle2 className="h-4 w-4 text-green-600" />
</Button>
<Button
variant="ghost" size="icon"
onClick={() => setRechazoFor(it)}
title="Rechazar"
>
<XCircle className="h-4 w-4 text-red-600" />
</Button>
</>
)}
<Button
variant="ghost" size="icon"
onClick={() => confirm(`¿Eliminar "${it.nombre}"?`) && eliminarMutation.mutate(it.id)}
title="Eliminar"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Modal Upload */}
<Dialog open={showUpload} onOpenChange={(o) => { setShowUpload(o); if (!o) resetUpload(); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Subir documento de papelería</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div>
<Label>Archivo (PDF, Word o Excel · máx 5 MB)</Label>
<input
type="file"
accept={ALLOWED_EXT}
onChange={e => {
const f = e.target.files?.[0] ?? null;
setFile(f);
if (f && !nombre) setNombre(f.name.replace(/\.[^.]+$/, ''));
setUploadError(null);
}}
className="block w-full text-sm border rounded-md px-3 py-2"
/>
{file && (
<p className="text-xs text-muted-foreground mt-1">
{file.name} · {(file.size / 1024).toFixed(0)} KB
</p>
)}
</div>
<div>
<Label>Nombre</Label>
<Input value={nombre} onChange={e => setNombre(e.target.value)} placeholder="Ej. Reporte de cuentas" />
</div>
<div>
<Label>Descripción (opcional)</Label>
<Input value={descripcion} onChange={e => setDescripcion(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Mes</Label>
<select
value={mes}
onChange={e => setMes(parseInt(e.target.value, 10))}
className="w-full h-10 rounded-md border bg-background px-3 text-sm"
>
{MESES.map((m, i) => <option key={i + 1} value={i + 1}>{m}</option>)}
</select>
</div>
<div>
<Label>Año</Label>
<Input
type="number" min={2020} max={2100}
value={anio}
onChange={e => setAnio(parseInt(e.target.value, 10) || currentYear)}
/>
</div>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={requiereAprobacion}
onChange={e => setRequiereAprobacion(e.target.checked)}
/>
Este documento requiere aprobación de owner/supervisor
</label>
{uploadError && (
<p className="text-xs text-destructive flex items-start gap-1">
<AlertTriangle className="h-3 w-3 mt-0.5 flex-shrink-0" />
<span>{uploadError}</span>
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowUpload(false)}>Cancelar</Button>
<Button
onClick={() => uploadMutation.mutate()}
disabled={!file || uploadMutation.isPending}
>
{uploadMutation.isPending ? 'Subiendo...' : 'Subir'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Modal Rechazo */}
<Dialog open={!!rechazoFor} onOpenChange={(o) => { if (!o) { setRechazoFor(null); setComentarioRechazo(''); } }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Rechazar documento</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<p className="text-sm">
Vas a rechazar <strong>{rechazoFor?.nombre}</strong>. El comentario es opcional.
</p>
<div>
<Label>Comentario (opcional)</Label>
<Input
value={comentarioRechazo}
onChange={e => setComentarioRechazo(e.target.value)}
placeholder="Motivo del rechazo..."
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setRechazoFor(null); setComentarioRechazo(''); }}>
Cancelar
</Button>
<Button
onClick={() => rechazoFor && rechazarMutation.mutate({ id: rechazoFor.id, comentario: comentarioRechazo || null })}
className={cn('bg-destructive hover:bg-destructive/90 text-destructive-foreground')}
>
Rechazar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import { Info } from 'lucide-react';
/**
* Disclaimer legal/fiscal mostrado al pie de páginas con cálculos estimados
* (dashboard, impuestos, reportes). Acota la responsabilidad legal de Horux 360
* frente al usuario y el SAT.
*/
export function FiscalDisclaimer() {
return (
<div className="mt-8 border-t pt-4 flex items-start gap-2 text-xs text-muted-foreground">
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
<p className="leading-relaxed">
Cálculos estimados generados automáticamente con base en reglas fiscales vigentes.
Validar con un contador. Horux 360 no se responsabiliza por discrepancias ante el SAT.
</p>
</div>
);
}

View File

@@ -0,0 +1,369 @@
'use client';
import { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Card, CardContent, CardHeader, CardTitle, Button, Input, Label,
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
KpiCard, cn,
} from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import {
Wallet, Calendar, AlertTriangle, CheckCircle2, Trash2, RotateCcw,
Building2, TrendingUp, Clock, CircleSlash, Filter,
} from 'lucide-react';
import { formatCurrency } from '@/lib/utils';
interface ActivoFijoItem {
cfdiId: number;
uuid: string;
fechaEmision: string;
rfcEmisor: string;
nombreEmisor: string;
usoCfdi: string;
concepto: string;
porcentajeAnual: number;
porcentajeMensual: number;
total: number;
iva: number;
moi: number;
acumuladoHastaMesAnterior: number;
acreditableEsteMes: number;
saldoPendiente: number;
estado: 'activo' | 'agotado' | 'baja_venta' | 'baja_desecho' | 'baja_otro';
baja: { fechaBaja: string; motivo: string; comentario: string | null } | null;
}
interface Totales {
cantidad: number;
totalMoi: number;
totalAcumuladoPrevio: number;
totalEsteMes: number;
totalSaldoPendiente: number;
cantidadActivos: number;
cantidadAgotados: number;
cantidadDeBaja: number;
}
interface Response {
items: ActivoFijoItem[];
totales: Totales;
usosExcluidos: string[];
}
const USOS_DISPONIBLES: { clave: string; concepto: string }[] = [
{ clave: 'I01', concepto: 'Construcciones' },
{ clave: 'I02', concepto: 'Mobiliario y equipo de oficina' },
{ clave: 'I03', concepto: 'Equipo de transporte' },
{ clave: 'I04', concepto: 'Equipo de cómputo y accesorios' },
{ clave: 'I05', concepto: 'Dados, troqueles, moldes, matrices' },
{ clave: 'I06', concepto: 'Comunicaciones telefónicas' },
{ clave: 'I07', concepto: 'Comunicaciones satelitales' },
{ clave: 'I08', concepto: 'Otra maquinaria y equipo' },
];
const ESTADO_LABEL: Record<string, { label: string; color: string }> = {
activo: { label: 'Activo', color: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' },
agotado: { label: 'Agotado', color: 'bg-muted text-muted-foreground' },
baja_venta: { label: 'Vendido', color: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400' },
baja_desecho: { label: 'Desechado', color: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' },
baja_otro: { label: 'Baja', color: 'bg-zinc-100 text-zinc-800 dark:bg-zinc-900/30 dark:text-zinc-400' },
};
export function ActivosFijosTab({ año, mes }: { año: number; mes: number }) {
const queryClient = useQueryClient();
const { selectedContribuyenteId } = useContribuyenteStore();
const [filtroEstado, setFiltroEstado] = useState<'todos' | 'activos' | 'baja' | 'agotados'>('todos');
const [bajaModal, setBajaModal] = useState<ActivoFijoItem | null>(null);
const [bajaForm, setBajaForm] = useState({
fechaBaja: new Date().toISOString().slice(0, 10),
motivo: 'venta' as 'venta' | 'desecho' | 'otro',
comentario: '',
});
const [conceptosModal, setConceptosModal] = useState(false);
const [conceptosDraft, setConceptosDraft] = useState<Set<string>>(new Set());
const { data, isLoading } = useQuery<Response>({
queryKey: ['activos-fijos', año, mes, selectedContribuyenteId, filtroEstado],
queryFn: async () => {
const p = new URLSearchParams({ año: String(año), mes: String(mes), estado: filtroEstado });
if (selectedContribuyenteId) p.set('contribuyenteId', selectedContribuyenteId);
const res = await apiClient.get<Response>(`/impuestos/activos-fijos?${p}`);
return res.data;
},
});
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['activos-fijos'] });
const bajaMutation = useMutation({
mutationFn: async () => {
if (!bajaModal) return;
await apiClient.post(`/impuestos/activos-fijos/${bajaModal.cfdiId}/baja`, {
fechaBaja: bajaForm.fechaBaja,
motivo: bajaForm.motivo,
comentario: bajaForm.comentario || null,
});
},
onSuccess: () => {
setBajaModal(null);
invalidate();
},
});
const revertirMutation = useMutation({
mutationFn: async (cfdiId: number) => apiClient.delete(`/impuestos/activos-fijos/${cfdiId}/baja`),
onSuccess: invalidate,
});
const conceptosMutation = useMutation({
mutationFn: async (excluidos: string[]) => {
if (!selectedContribuyenteId) return;
await apiClient.put('/impuestos/activos-fijos/usos-excluidos', {
contribuyenteId: selectedContribuyenteId,
usos: excluidos,
});
},
onSuccess: () => {
setConceptosModal(false);
invalidate();
},
});
const openConceptos = () => {
setConceptosDraft(new Set(data?.usosExcluidos ?? []));
setConceptosModal(true);
};
const toggleConcepto = (clave: string) => {
setConceptosDraft(prev => {
const next = new Set(prev);
if (next.has(clave)) next.delete(clave);
else next.add(clave);
return next;
});
};
const items = data?.items ?? [];
const t = data?.totales;
const openBaja = (a: ActivoFijoItem) => {
setBajaForm({
fechaBaja: a.baja?.fechaBaja ?? new Date().toISOString().slice(0, 10),
motivo: (a.baja?.motivo as 'venta' | 'desecho' | 'otro') ?? 'venta',
comentario: a.baja?.comentario ?? '',
});
setBajaModal(a);
};
return (
<div className="space-y-4">
{/* Disclaimer */}
<Card className="border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20">
<CardContent className="py-3 text-xs text-amber-900 dark:text-amber-100 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0" />
<div>
<strong>Vista informativa.</strong> El sistema considera estos CFDIs como gasto del periodo
(igual que el SAT), por lo que ya están en tu Dashboard y en tu cálculo de ISR.
Esta vista te sirve para llevar el seguimiento de la deducción mensual proporcional
(% anual ÷ 12) y decidir manualmente si la aplicas en tu declaración anual.
</div>
</CardContent>
</Card>
{/* KPIs */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<KpiCard title="Monto Original (MOI)" value={t?.totalMoi ?? 0} icon={<Wallet className="h-4 w-4" />} subtitle={`${t?.cantidad ?? 0} CFDIs`} />
<KpiCard title="Acumulado al mes anterior" value={t?.totalAcumuladoPrevio ?? 0} icon={<Calendar className="h-4 w-4" />} subtitle="Ya deducible" />
<KpiCard title="Acreditable este mes" value={t?.totalEsteMes ?? 0} icon={<TrendingUp className="h-4 w-4" />} subtitle="A aplicar este mes" />
<KpiCard title="Saldo pendiente" value={t?.totalSaldoPendiente ?? 0} icon={<Clock className="h-4 w-4" />} subtitle="Por deducir en futuro" />
</div>
{/* Filtros */}
<div className="flex items-center gap-3">
<Label className="text-xs text-muted-foreground">Mostrar:</Label>
<Select value={filtroEstado} onValueChange={(v) => setFiltroEstado(v as typeof filtroEstado)}>
<SelectTrigger className="w-40 h-9 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="todos">Todos</SelectItem>
<SelectItem value="activos">Activos</SelectItem>
<SelectItem value="agotados">Agotados</SelectItem>
<SelectItem value="baja">Dados de baja</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" onClick={openConceptos} disabled={!selectedContribuyenteId}>
<Filter className="h-4 w-4 mr-1" />
Conceptos
{data && data.usosExcluidos.length > 0 && (
<span className="ml-1 text-[10px] bg-amber-100 dark:bg-amber-900/40 text-amber-800 dark:text-amber-200 rounded px-1.5 py-0.5">
{data.usosExcluidos.length} excluidos
</span>
)}
</Button>
<span className="text-xs text-muted-foreground ml-auto">
{t ? `${t.cantidadActivos} activos · ${t.cantidadAgotados} agotados · ${t.cantidadDeBaja} bajas` : ''}
</span>
</div>
{/* Tabla */}
<Card>
<CardContent className="p-0">
{isLoading ? (
<p className="p-6 text-sm text-muted-foreground">Cargando...</p>
) : items.length === 0 ? (
<p className="p-6 text-sm text-muted-foreground text-center">
No hay activos fijos en el periodo seleccionado.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b bg-muted/50">
<tr>
<th className="text-left px-3 py-2 font-medium">Fecha</th>
<th className="text-left px-3 py-2 font-medium">Emisor</th>
<th className="text-left px-3 py-2 font-medium">Concepto</th>
<th className="text-right px-3 py-2 font-medium">MOI</th>
<th className="text-right px-3 py-2 font-medium">% anual</th>
<th className="text-right px-3 py-2 font-medium">Acum. previo</th>
<th className="text-right px-3 py-2 font-medium">Este mes</th>
<th className="text-right px-3 py-2 font-medium">Saldo</th>
<th className="text-center px-3 py-2 font-medium">Estado</th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{items.map(a => {
const estadoMeta = ESTADO_LABEL[a.estado] ?? ESTADO_LABEL.activo;
const esBaja = a.estado.startsWith('baja_');
return (
<tr key={a.cfdiId} className="border-b hover:bg-muted/30">
<td className="px-3 py-2 whitespace-nowrap">
{new Date(a.fechaEmision).toLocaleDateString('es-MX', { day: 'numeric', month: 'short', year: 'numeric' })}
</td>
<td className="px-3 py-2">
<div className="font-mono text-xs">{a.rfcEmisor}</div>
<div className="text-xs text-muted-foreground truncate max-w-[180px]">{a.nombreEmisor}</div>
</td>
<td className="px-3 py-2 text-xs">
<div className="font-mono">{a.usoCfdi}</div>
<div className="text-muted-foreground truncate max-w-[180px]">{a.concepto}</div>
</td>
<td className="px-3 py-2 text-right font-medium tabular-nums">{formatCurrency(a.moi)}</td>
<td className="px-3 py-2 text-right tabular-nums text-muted-foreground">{(a.porcentajeAnual * 100).toFixed(0)}%</td>
<td className="px-3 py-2 text-right tabular-nums text-muted-foreground">{formatCurrency(a.acumuladoHastaMesAnterior)}</td>
<td className="px-3 py-2 text-right tabular-nums font-medium text-success">{formatCurrency(a.acreditableEsteMes)}</td>
<td className="px-3 py-2 text-right tabular-nums">{formatCurrency(a.saldoPendiente)}</td>
<td className="px-3 py-2 text-center">
<span className={cn('inline-block px-2 py-0.5 rounded-full text-[10px] font-medium', estadoMeta.color)}>
{estadoMeta.label}
</span>
</td>
<td className="px-3 py-2 text-right">
{esBaja ? (
<Button
variant="ghost" size="icon" title="Revertir baja"
onClick={() => revertirMutation.mutate(a.cfdiId)}
>
<RotateCcw className="h-4 w-4" />
</Button>
) : a.estado === 'activo' ? (
<Button
variant="ghost" size="icon" title="Dar de baja"
onClick={() => openBaja(a)}
>
<CircleSlash className="h-4 w-4 text-destructive" />
</Button>
) : null}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* Modal conceptos: excluir usos CFDI que el contador no quiere ver */}
<Dialog open={conceptosModal} onOpenChange={(o) => { if (!o) setConceptosModal(false); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Conceptos a considerar como activos fijos</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
<p className="text-sm text-muted-foreground">
Desmarca los conceptos cuyos CFDIs en este contribuyente NO sean adquisiciones de activos fijos
(ej. servicio telefónico mensual con uso I06). Por default todos están considerados.
</p>
{USOS_DISPONIBLES.map(u => {
const excluido = conceptosDraft.has(u.clave);
return (
<label key={u.clave} className="flex items-start gap-2 cursor-pointer text-sm py-1">
<input
type="checkbox"
checked={!excluido}
onChange={() => toggleConcepto(u.clave)}
className="mt-0.5"
/>
<div className="flex-1">
<span className="font-mono text-xs mr-2">{u.clave}</span>
<span>{u.concepto}</span>
</div>
</label>
);
})}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setConceptosModal(false)}>Cancelar</Button>
<Button
onClick={() => conceptosMutation.mutate([...conceptosDraft])}
disabled={conceptosMutation.isPending}
>
{conceptosMutation.isPending ? 'Guardando...' : 'Guardar'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Modal baja */}
<Dialog open={!!bajaModal} onOpenChange={(o) => { if (!o) setBajaModal(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Dar de baja activo fijo</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
<strong>{bajaModal?.concepto}</strong> {bajaModal?.nombreEmisor}
</p>
<div>
<Label>Fecha de baja</Label>
<Input type="date" value={bajaForm.fechaBaja} onChange={e => setBajaForm(f => ({ ...f, fechaBaja: e.target.value }))} />
</div>
<div>
<Label>Motivo</Label>
<Select value={bajaForm.motivo} onValueChange={(v) => setBajaForm(f => ({ ...f, motivo: v as typeof bajaForm.motivo }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="venta">Venta</SelectItem>
<SelectItem value="desecho">Desecho</SelectItem>
<SelectItem value="otro">Otro</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Comentario (opcional)</Label>
<Input value={bajaForm.comentario} onChange={e => setBajaForm(f => ({ ...f, comentario: e.target.value }))} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setBajaModal(null)}>Cancelar</Button>
<Button onClick={() => bajaMutation.mutate()} disabled={bajaMutation.isPending}>
{bajaMutation.isPending ? 'Guardando...' : 'Dar de baja'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import { Header } from './header';
interface DashboardShellProps {
children: React.ReactNode;
title: string;
headerContent?: React.ReactNode;
}
export function DashboardShell({ children, title, headerContent }: DashboardShellProps) {
// Navigation is handled by the parent layout.tsx which respects theme settings
// DashboardShell only provides Header and content wrapper
return (
<>
<Header title={title}>{headerContent}</Header>
<main className="p-6">{children}</main>
</>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import { useThemeStore } from '@/stores/theme-store';
import { themes, type ThemeName } from '@/themes';
import { Button } from '@horux/shared-ui';
import { TenantSelector } from '@/components/tenant-selector';
import { MembershipSwitcher } from '@/components/membership-switcher';
import { ContribuyenteSelector } from '@/components/contribuyente-selector';
import { Sun, Moon, Palette } from 'lucide-react';
const themeIcons: Record<ThemeName, React.ReactNode> = {
light: <Sun className="h-4 w-4" />,
vibrant: <Palette className="h-4 w-4" />,
corporate: <Palette className="h-4 w-4" />,
dark: <Moon className="h-4 w-4" />,
};
const themeOrder: ThemeName[] = ['light', 'dark'];
interface HeaderProps {
title: string;
children?: React.ReactNode;
}
export function Header({ title, children }: HeaderProps) {
const { theme, setTheme } = useThemeStore();
const cycleTheme = () => {
const currentIndex = themeOrder.indexOf(theme);
const nextIndex = (currentIndex + 1) % themeOrder.length;
setTheme(themeOrder[nextIndex]);
};
return (
<header className="sticky top-0 z-30 flex h-16 items-center justify-between border-b bg-background/95 backdrop-blur px-6">
<div className="flex items-center gap-4 min-w-0">
<h1 className="text-xl font-semibold whitespace-nowrap">{title}</h1>
{children}
</div>
<div className="flex items-center gap-3">
<ContribuyenteSelector />
<MembershipSwitcher />
<TenantSelector />
<Button
variant="ghost"
size="icon"
onClick={cycleTheme}
title={`Tema: ${themes[theme].name}`}
>
{themeIcons[theme]}
</Button>
</div>
</header>
);
}

View File

@@ -0,0 +1,158 @@
'use client';
import Link from 'next/link';
import Image from 'next/image';
import { usePathname } from 'next/navigation';
import { cn } from '@horux/shared-ui';
import {
LayoutDashboard,
FileText,
Calculator,
Settings,
LogOut,
BarChart3,
Calendar,
Bell,
Users,
Building2,
Scale,
Send,
} from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import { logout } from '@/lib/api/auth';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { hasFeature, isGlobalAdminRfc, type Plan } from '@horux/shared';
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, roles: ['owner', 'contador'] },
{ name: 'CFDI', href: '/cfdi', icon: FileText },
{ name: 'Impuestos', href: '/impuestos', icon: Calculator },
{ name: 'Reportes', href: '/reportes', icon: BarChart3, feature: 'reportes', roles: ['owner'] },
{ name: 'Conciliacion', href: '/conciliacion', icon: Scale, feature: 'conciliacion' },
{ name: 'Calendario', href: '/calendario', icon: Calendar, feature: 'calendario' },
{ name: 'Alertas', href: '/alertas', icon: Bell, feature: 'alertas' },
{ name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'contador'] },
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner'] },
{ name: 'Configuración', href: '/configuracion', icon: Settings, roles: ['owner'] },
] as const;
const adminNavigation = [
{ name: 'Clientes', href: '/clientes', icon: Building2 },
];
export function SidebarCompact() {
const pathname = usePathname();
const router = useRouter();
const { user, logout: clearAuth } = useAuthStore();
const [expanded, setExpanded] = useState(false);
const plan = (user?.plan || 'starter') as Plan;
const role = user?.role || 'visor';
const filteredNav = navigation.filter((item) => {
if ('feature' in item && item.feature && !hasFeature(plan, item.feature)) return false;
if ('roles' in item && item.roles && !item.roles.includes(role)) return false;
return true;
});
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, role, user?.platformRoles);
const allNavigation = isGlobalAdmin
? [...filteredNav.slice(0, -1), ...adminNavigation, filteredNav[filteredNav.length - 1]]
: filteredNav;
const handleLogout = async () => {
try {
await logout();
} catch {
// Ignore errors
} finally {
clearAuth();
router.push('/login');
}
};
return (
<aside
className={cn(
'fixed left-0 top-0 z-40 h-screen border-r bg-card transition-all duration-300',
expanded ? 'w-64' : 'w-16'
)}
onMouseEnter={() => setExpanded(true)}
onMouseLeave={() => setExpanded(false)}
>
<div className="flex h-full flex-col">
{/* Logo */}
<div className="flex h-14 items-center border-b px-4">
<Link href="/dashboard" className="flex items-center gap-2">
<Image
src="/logo.jpg"
alt="Horux360"
width={32}
height={32}
className="rounded-full flex-shrink-0"
/>
<span className={cn(
'font-bold text-lg whitespace-nowrap transition-opacity duration-300',
expanded ? 'opacity-100' : 'opacity-0 w-0 overflow-hidden'
)}>
Horux360
</span>
</Link>
</div>
{/* Navigation */}
<nav className="flex-1 space-y-1 px-2 py-3">
{allNavigation.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link
key={item.name}
href={item.href}
className={cn(
'flex items-center gap-3 rounded px-2 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
title={!expanded ? item.name : undefined}
>
<item.icon className="h-5 w-5 flex-shrink-0" />
<span className={cn(
'whitespace-nowrap transition-opacity duration-300',
expanded ? 'opacity-100' : 'opacity-0 w-0 overflow-hidden'
)}>
{item.name}
</span>
</Link>
);
})}
</nav>
{/* User & Logout */}
<div className="border-t p-2">
{expanded && (
<div className="mb-2 px-2 py-1">
<p className="text-xs font-medium truncate">{user?.nombre}</p>
<p className="text-xs text-muted-foreground truncate">{user?.email}</p>
</div>
)}
<button
onClick={handleLogout}
className={cn(
'flex w-full items-center gap-3 rounded px-2 py-2 text-sm font-medium text-muted-foreground hover:bg-destructive hover:text-destructive-foreground transition-colors'
)}
title={!expanded ? 'Cerrar sesión' : undefined}
>
<LogOut className="h-5 w-5 flex-shrink-0" />
<span className={cn(
'whitespace-nowrap transition-opacity duration-300',
expanded ? 'opacity-100' : 'opacity-0 w-0 overflow-hidden'
)}>
Cerrar sesión
</span>
</button>
</div>
</div>
</aside>
);
}

View File

@@ -0,0 +1,139 @@
'use client';
import Link from 'next/link';
import Image from 'next/image';
import { usePathname } from 'next/navigation';
import { cn } from '@horux/shared-ui';
import {
LayoutDashboard,
FileText,
Calculator,
Settings,
LogOut,
BarChart3,
Calendar,
Bell,
Users,
Building2,
Scale,
Send,
} from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import { logout } from '@/lib/api/auth';
import { useRouter } from 'next/navigation';
import { hasFeature, isGlobalAdminRfc, type Plan } from '@horux/shared';
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, roles: ['owner', 'contador'] },
{ name: 'CFDI', href: '/cfdi', icon: FileText },
{ name: 'Impuestos', href: '/impuestos', icon: Calculator },
{ name: 'Reportes', href: '/reportes', icon: BarChart3, feature: 'reportes', roles: ['owner'] },
{ name: 'Conciliacion', href: '/conciliacion', icon: Scale, feature: 'conciliacion' },
{ name: 'Calendario', href: '/calendario', icon: Calendar, feature: 'calendario' },
{ name: 'Alertas', href: '/alertas', icon: Bell, feature: 'alertas' },
{ name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'contador'] },
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner'] },
{ name: 'Config', href: '/configuracion', icon: Settings, roles: ['owner'] },
] as const;
const adminNavigation = [
{ name: 'Clientes', href: '/clientes', icon: Building2 },
];
export function SidebarFloating() {
const pathname = usePathname();
const router = useRouter();
const { user, logout: clearAuth } = useAuthStore();
const plan = (user?.plan || 'starter') as Plan;
const role = user?.role || 'visor';
const filteredNav = navigation.filter((item) => {
if ('feature' in item && item.feature && !hasFeature(plan, item.feature)) return false;
if ('roles' in item && item.roles && !item.roles.includes(role)) return false;
return true;
});
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, role, user?.platformRoles);
const allNavigation = isGlobalAdmin
? [...filteredNav.slice(0, -1), ...adminNavigation, filteredNav[filteredNav.length - 1]]
: filteredNav;
const handleLogout = async () => {
try {
await logout();
} catch {
// Ignore errors
} finally {
clearAuth();
router.push('/login');
}
};
return (
<aside className="fixed left-4 top-4 bottom-4 z-40 w-64 rounded-2xl border border-border/50 bg-card/80 backdrop-blur-xl shadow-2xl shadow-primary/5">
<div className="flex h-full flex-col p-4">
{/* Logo */}
<div className="flex items-center gap-3 mb-6 px-2">
<Image
src="/logo.jpg"
alt="Horux360"
width={40}
height={40}
className="rounded-full shadow-lg shadow-primary/25"
/>
<div>
<span className="font-bold text-lg block">Horux360</span>
<span className="text-xs text-muted-foreground">Análisis Fiscal</span>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 space-y-1">
{allNavigation.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link
key={item.name}
href={item.href}
className={cn(
'flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-all duration-200',
isActive
? 'bg-primary/20 text-primary shadow-sm shadow-primary/20 border border-primary/30'
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
)}
>
<item.icon className={cn(
'h-5 w-5 transition-transform',
isActive && 'scale-110'
)} />
{item.name}
</Link>
);
})}
</nav>
{/* User & Logout */}
<div className="mt-4 pt-4 border-t border-border/50">
<div className="flex items-center gap-3 px-2 mb-3">
<div className="h-10 w-10 rounded-xl bg-gradient-to-br from-secondary to-muted flex items-center justify-center">
<span className="text-foreground font-medium">
{user?.nombre?.charAt(0).toUpperCase()}
</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{user?.nombre}</p>
<p className="text-xs text-muted-foreground truncate">{user?.email}</p>
</div>
</div>
<button
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-muted-foreground hover:bg-destructive/20 hover:text-destructive transition-colors"
>
<LogOut className="h-5 w-5" />
Cerrar sesión
</button>
</div>
</div>
</aside>
);
}

View File

@@ -0,0 +1,186 @@
'use client';
import Link from 'next/link';
import Image from 'next/image';
import { usePathname } from 'next/navigation';
import { cn } from '@horux/shared-ui';
import {
LayoutDashboard,
FileText,
Calculator,
Settings,
LogOut,
BarChart3,
Calendar,
Bell,
Users,
Building2,
UserCog,
CreditCard,
Send,
Scale,
FileCheck,
FileWarning,
Shield,
Rocket,
ClipboardList,
ListChecks,
} from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import { logout } from '@/lib/api/auth';
import { useContribuyentes } from '@/lib/hooks/use-contribuyentes';
import { useRouter } from 'next/navigation';
import { hasFeature, isGlobalAdminRfc, isDespachoTenant, type Plan } from '@horux/shared';
interface NavItem {
name: string;
href: string;
icon: typeof LayoutDashboard;
feature?: string; // Required plan feature — hidden if tenant's plan lacks it
roles?: string[]; // Allowed roles — hidden if user's role is not in the list
/** Visible solo si el user es owner en algún tenant (no en el activo). */
requireOwnerSomewhere?: boolean;
}
const navigation: NavItem[] = [
{ name: 'Despacho', href: '/despachos', icon: ListChecks, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor', 'cliente'] },
{ name: 'CFDI', href: '/cfdi', icon: FileText },
{ name: 'Impuestos', href: '/impuestos', icon: Calculator },
{ name: 'Reportes', href: '/reportes', icon: BarChart3, feature: 'reportes', roles: ['owner', 'cfo', 'supervisor', 'cliente'] },
{ name: 'Conciliacion', href: '/conciliacion', icon: Scale, feature: 'conciliacion' },
{ name: 'Calendario', href: '/calendario', icon: Calendar, feature: 'calendario' },
{ name: 'Alertas', href: '/alertas', icon: Bell, feature: 'alertas' },
{ name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
{ name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' },
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] },
{ name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] },
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo'] },
];
const adminNavigation: NavItem[] = [
{ name: 'Clientes', href: '/clientes', icon: Building2 },
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
{ name: 'Staff', href: '/admin/staff', icon: Shield },
{ name: 'Audit Log', href: '/admin/audit-log', icon: FileWarning },
];
export function Sidebar() {
const pathname = usePathname();
const router = useRouter();
const { user, logout: clearAuth } = useAuthStore();
const handleLogout = async () => {
try {
await logout();
} catch {
// Ignore errors
} finally {
clearAuth();
router.push('/login');
}
};
// Filter navigation based on plan features + user role
const plan = (user?.plan || 'starter') as Plan;
const role = user?.role || 'visor';
const isOwnerSomewhere = (user?.tenants || []).some(t => t.isOwner);
const isDespacho = isDespachoTenant(user?.tenantRfc);
const filteredNav = navigation.filter((item) => {
if (item.feature) {
if (isDespacho) {
// Despacho tenants: all features are enabled across all plans — skip check
} else {
// Horux360: use legacy plan feature gating
if (!hasFeature(plan, item.feature)) return false;
}
}
if (item.roles && !item.roles.includes(role)) return false;
if (item.requireOwnerSomewhere && !isOwnerSomewhere) return false;
return true;
});
const { data: contribuyentes } = useContribuyentes();
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, role, user?.platformRoles);
// El admin global NO necesita "Configuración inicial" — su tenant raíz
// (Horux 360) no tiene contribuyentes propios y nunca los tendrá.
const showOnboarding = (!contribuyentes || contribuyentes.length === 0) && role !== 'cliente' && !isGlobalAdmin;
const allNavigation = isGlobalAdmin
? [...filteredNav.slice(0, -1), ...adminNavigation, filteredNav[filteredNav.length - 1]]
: filteredNav;
return (
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r bg-card">
<div className="flex h-full flex-col">
{/* Logo */}
<div className="flex h-16 items-center border-b px-6">
<Link href="/dashboard" className="flex items-center gap-2">
<Image
src="/logo.jpg"
alt="Horux Despachos"
width={32}
height={32}
className="rounded-full"
/>
<div className="flex flex-col leading-tight">
<span className="font-bold text-xl">Horux</span>
<span className="text-xs text-muted-foreground -mt-1">Despachos</span>
</div>
</Link>
</div>
{/* Navigation */}
<nav className="flex-1 space-y-1 px-3 py-4">
{showOnboarding && (
<div className="px-3 py-2">
<Link href="/onboarding">
<div className="flex items-center gap-2 px-3 py-2 rounded-md bg-primary/10 text-primary text-sm font-medium hover:bg-primary/20 transition-colors">
<Rocket className="h-4 w-4" />
Configuración inicial
</div>
</Link>
</div>
)}
{allNavigation.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link
key={item.name}
href={item.href}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
>
<item.icon className="h-5 w-5" />
{item.name}
</Link>
);
})}
</nav>
{/* User & Logout — admin/TI globales no muestran nombre+email para
mantener el sidebar más limpio (ya tienen muchos items extras) */}
<div className="border-t p-4">
{!isGlobalAdmin && (
<div className="mb-3 px-3">
<p className="text-sm font-medium">{user?.nombre}</p>
<p className="text-xs text-muted-foreground">{user?.email}</p>
</div>
)}
<button
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-destructive hover:text-destructive-foreground transition-colors"
>
<LogOut className="h-5 w-5" />
Cerrar sesion
</button>
</div>
</div>
</aside>
);
}

View File

@@ -0,0 +1,141 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@horux/shared-ui';
import {
LayoutDashboard,
FileText,
Calculator,
Settings,
LogOut,
BarChart3,
Calendar,
Bell,
Users,
ChevronDown,
Building2,
Scale,
Send,
} from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import { logout } from '@/lib/api/auth';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { hasFeature, isGlobalAdminRfc, type Plan } from '@horux/shared';
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, roles: ['owner', 'contador'] },
{ name: 'CFDI', href: '/cfdi', icon: FileText },
{ name: 'Impuestos', href: '/impuestos', icon: Calculator },
{ name: 'Reportes', href: '/reportes', icon: BarChart3, feature: 'reportes', roles: ['owner'] },
{ name: 'Conciliacion', href: '/conciliacion', icon: Scale, feature: 'conciliacion' },
{ name: 'Calendario', href: '/calendario', icon: Calendar, feature: 'calendario' },
{ name: 'Alertas', href: '/alertas', icon: Bell, feature: 'alertas' },
{ name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'contador'] },
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner'] },
{ name: 'Config', href: '/configuracion', icon: Settings, roles: ['owner'] },
] as const;
const adminNavigation = [
{ name: 'Clientes', href: '/clientes', icon: Building2 },
];
export function TopNav() {
const pathname = usePathname();
const router = useRouter();
const { user, logout: clearAuth } = useAuthStore();
const [userMenuOpen, setUserMenuOpen] = useState(false);
const plan = (user?.plan || 'starter') as Plan;
const role = user?.role || 'visor';
const filteredNav = navigation.filter((item) => {
if ('feature' in item && item.feature && !hasFeature(plan, item.feature)) return false;
if ('roles' in item && item.roles && !item.roles.includes(role)) return false;
return true;
});
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, role, user?.platformRoles);
const allNavigation = isGlobalAdmin
? [...filteredNav.slice(0, -1), ...adminNavigation, filteredNav[filteredNav.length - 1]]
: filteredNav;
const handleLogout = async () => {
try {
await logout();
} catch {
// Ignore errors
} finally {
clearAuth();
router.push('/login');
}
};
return (
<header className="fixed top-0 left-0 right-0 z-40 h-16 border-b bg-card">
<div className="flex h-full items-center px-6">
{/* Logo */}
<Link href="/dashboard" className="flex items-center gap-2 mr-8">
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center">
<span className="text-primary-foreground font-bold text-lg">H</span>
</div>
<span className="font-bold text-xl">Horux360</span>
</Link>
{/* Navigation */}
<nav className="flex-1 flex items-center gap-1">
{allNavigation.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link
key={item.name}
href={item.href}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
>
<item.icon className="h-4 w-4" />
<span className="hidden lg:inline">{item.name}</span>
</Link>
);
})}
</nav>
{/* User Menu */}
<div className="relative">
<button
onClick={() => setUserMenuOpen(!userMenuOpen)}
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium hover:bg-accent transition-colors"
>
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
<span className="text-primary font-medium text-sm">
{user?.nombre?.charAt(0).toUpperCase()}
</span>
</div>
<span className="hidden md:inline">{user?.nombre}</span>
<ChevronDown className="h-4 w-4" />
</button>
{userMenuOpen && (
<div className="absolute right-0 top-full mt-2 w-48 rounded-lg border bg-card shadow-lg">
<div className="p-3 border-b">
<p className="text-sm font-medium">{user?.nombre}</p>
<p className="text-xs text-muted-foreground">{user?.email}</p>
</div>
<button
onClick={handleLogout}
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-destructive hover:bg-destructive/10 transition-colors"
>
<LogOut className="h-4 w-4" />
Cerrar sesión
</button>
</div>
)}
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,114 @@
'use client';
import { useState, useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '@/stores/auth-store';
import { switchTenant } from '@/lib/api/auth';
import { Building2, ChevronDown, Check, Loader2, Crown } from 'lucide-react';
import { cn } from '@horux/shared-ui';
import { isGlobalAdminRfc } from '@horux/shared';
/**
* Switcher para users con múltiples memberships (owner o contador con varias
* empresas). Distinto del TenantSelector de admin global:
* - Admin global: impersonación via X-View-Tenant (no cambia el JWT)
* - Membership switcher: cambia de tenant *real* con nuevo JWT
*
* Se oculta si:
* - El user tiene ≤1 membership
* - El user es admin global (ya tiene su propio TenantSelector, sería redundante)
*/
export function MembershipSwitcher() {
const [open, setOpen] = useState(false);
const [switching, setSwitching] = useState(false);
const { user, setUser, setTokens } = useAuthStore();
const queryClient = useQueryClient();
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
const tenants = user?.tenants || [];
const showSwitcher = !isGlobalAdmin && tenants.length > 1;
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest('.membership-switcher')) setOpen(false);
};
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}, []);
if (!showSwitcher) return null;
const activeTenant = tenants.find(t => t.id === user?.tenantId);
const handleSwitch = async (tenantId: string) => {
if (tenantId === user?.tenantId) { setOpen(false); return; }
setSwitching(true);
try {
const res = await switchTenant(tenantId);
setTokens(res.accessToken, res.refreshToken);
setUser(res.user);
// Refresca todo el cache — las queries dependen del tenant activo
queryClient.clear();
setOpen(false);
// Reload para que React Query re-fetche desde cero con el nuevo JWT
window.location.reload();
} catch (err: any) {
alert(err?.response?.data?.message || 'Error al cambiar de empresa');
} finally {
setSwitching(false);
}
};
return (
<div className="membership-switcher relative">
<button
onClick={() => setOpen(!open)}
disabled={switching}
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium hover:bg-accent transition-colors disabled:opacity-50"
>
<Building2 className="h-4 w-4" />
<span className="max-w-[180px] truncate">
{activeTenant?.nombre || user?.tenantName}
</span>
{switching
? <Loader2 className="h-4 w-4 animate-spin" />
: <ChevronDown className={cn('h-4 w-4 transition-transform', open && 'rotate-180')} />
}
</button>
{open && (
<div className="absolute top-full right-0 mt-2 w-72 rounded-lg border bg-card shadow-lg z-50">
<div className="p-2 border-b">
<p className="text-xs text-muted-foreground px-2">Mis empresas</p>
</div>
<div className="max-h-80 overflow-y-auto p-1">
{tenants.map(t => (
<button
key={t.id}
onClick={() => handleSwitch(t.id)}
disabled={switching}
className={cn(
'flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors text-left',
t.id === user?.tenantId && 'bg-primary/10'
)}
>
<div className="h-8 w-8 rounded bg-muted flex items-center justify-center text-xs font-medium">
{t.nombre.substring(0, 2).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<p className="font-medium truncate">{t.nombre}</p>
{t.isOwner && <Crown className="h-3 w-3 text-amber-500 flex-shrink-0" />}
</div>
<p className="text-xs text-muted-foreground truncate">{t.rfc} · {t.role}</p>
</div>
{t.id === user?.tenantId && <Check className="h-4 w-4 text-primary flex-shrink-0" />}
</button>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,325 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Button, Card, CardContent, Input, Label,
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { Plus, Trash2, Edit, CheckCircle2, Circle, Sparkles } from 'lucide-react';
const RECURRENCIAS = [
{ value: 'semanal', label: 'Semanal' },
{ value: 'quincenal', label: 'Quincenal' },
{ value: 'mensual', label: 'Mensual' },
{ value: 'bimestral', label: 'Bimestral' },
{ value: 'trimestral', label: 'Trimestral' },
{ value: 'semestral', label: 'Semestral' },
{ value: 'anual', label: 'Anual' },
];
const DIAS_SEMANA = [
{ value: 1, label: 'Lunes' },
{ value: 2, label: 'Martes' },
{ value: 3, label: 'Miércoles' },
{ value: 4, label: 'Jueves' },
{ value: 5, label: 'Viernes' },
{ value: 6, label: 'Sábado' },
{ value: 7, label: 'Domingo' },
];
interface Tarea {
id: string;
contribuyenteId: string;
nombre: string;
descripcion: string | null;
recurrencia: string;
diaSemana: number | null;
diaMes: number | null;
soloSupervisorCompleta: boolean;
esDefault: boolean;
active: boolean;
orden: number;
periodoActual: {
id: string;
fechaLimite: string;
completada: boolean;
completadaAt: string | null;
} | null;
}
interface FormState {
nombre: string;
descripcion: string;
recurrencia: string;
diaSemana: number;
diaMes: number;
soloSupervisorCompleta: boolean;
}
const EMPTY_FORM: FormState = {
nombre: '',
descripcion: '',
recurrencia: 'mensual',
diaSemana: 5,
diaMes: 10,
soloSupervisorCompleta: false,
};
export function TareasTab({ contribuyenteId }: { contribuyenteId: string | null }) {
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const tareasQuery = useQuery<Tarea[]>({
queryKey: ['tareas', contribuyenteId],
queryFn: async () => {
const params = new URLSearchParams({ contribuyenteId: contribuyenteId! });
const { data } = await apiClient.get<Tarea[]>(`/tareas?${params}`);
return data;
},
enabled: !!contribuyenteId,
});
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['tareas', contribuyenteId] });
const seedMutation = useMutation({
mutationFn: async () => {
const params = new URLSearchParams({ contribuyenteId: contribuyenteId! });
await apiClient.post(`/tareas/seed?${params}`);
},
onSuccess: invalidate,
});
const saveMutation = useMutation({
mutationFn: async () => {
const payload = {
nombre: form.nombre,
descripcion: form.descripcion || null,
recurrencia: form.recurrencia,
diaSemana: form.recurrencia === 'semanal' || form.recurrencia === 'quincenal' ? form.diaSemana : null,
diaMes: form.recurrencia === 'semanal' || form.recurrencia === 'quincenal' ? null : form.diaMes,
soloSupervisorCompleta: form.soloSupervisorCompleta,
};
if (editingId) {
await apiClient.patch(`/tareas/${editingId}`, payload);
} else {
const params = new URLSearchParams({ contribuyenteId: contribuyenteId! });
await apiClient.post(`/tareas?${params}`, payload);
}
},
onSuccess: () => {
setShowForm(false);
setEditingId(null);
setForm(EMPTY_FORM);
invalidate();
},
});
const deleteMutation = useMutation({
mutationFn: async (id: string) => apiClient.delete(`/tareas/${id}`),
onSuccess: invalidate,
});
const completarMutation = useMutation({
mutationFn: async (periodoId: string) => apiClient.post(`/tareas/periodo/${periodoId}/completar`),
onSuccess: invalidate,
onError: (err: unknown) => {
const e = err as { response?: { data?: { message?: string } } };
alert(e.response?.data?.message || 'No se pudo marcar como completada');
},
});
const descompletarMutation = useMutation({
mutationFn: async (periodoId: string) => apiClient.delete(`/tareas/periodo/${periodoId}/completar`),
onSuccess: invalidate,
});
const handleEdit = (t: Tarea) => {
setEditingId(t.id);
setForm({
nombre: t.nombre,
descripcion: t.descripcion ?? '',
recurrencia: t.recurrencia,
diaSemana: t.diaSemana ?? 5,
diaMes: t.diaMes ?? 10,
soloSupervisorCompleta: t.soloSupervisorCompleta,
});
setShowForm(true);
};
const handleNew = () => {
setEditingId(null);
setForm(EMPTY_FORM);
setShowForm(true);
};
if (!contribuyenteId) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
Selecciona un contribuyente para gestionar sus tareas.
</CardContent>
</Card>
);
}
const tareas = tareasQuery.data ?? [];
const isWeekly = form.recurrencia === 'semanal' || form.recurrencia === 'quincenal';
return (
<div className="space-y-4">
<div className="flex items-center justify-end gap-2">
{tareas.length === 0 && (
<Button variant="outline" onClick={() => seedMutation.mutate()} disabled={seedMutation.isPending}>
<Sparkles className="h-4 w-4 mr-2" />
Generar recomendaciones
</Button>
)}
<Button onClick={handleNew}>
<Plus className="h-4 w-4 mr-2" /> Agregar tarea
</Button>
</div>
{tareasQuery.isLoading ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
) : tareas.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No hay tareas configuradas. Usa "Generar recomendaciones" para crear las 4 tareas default
(estados de cuenta, conciliación, contabilización, revisión fiscal preliminar).
</CardContent>
</Card>
) : (
<div className="space-y-2">
{tareas.map(t => {
const p = t.periodoActual;
const fl = p ? new Date(p.fechaLimite) : null;
const today = new Date(); today.setHours(0, 0, 0, 0);
const atrasada = !!fl && !p?.completada && fl < today;
const recurrenciaLabel = RECURRENCIAS.find(r => r.value === t.recurrencia)?.label;
const cuandoLabel = (t.recurrencia === 'semanal' || t.recurrencia === 'quincenal')
? DIAS_SEMANA.find(d => d.value === t.diaSemana)?.label
: `día ${t.diaMes}`;
return (
<Card key={t.id}>
<CardContent className="py-3 flex items-center gap-3">
<button
onClick={() => p && (p.completada ? descompletarMutation.mutate(p.id) : completarMutation.mutate(p.id))}
disabled={!p || completarMutation.isPending}
title={p?.completada ? 'Marcar pendiente' : 'Marcar completada'}
className="flex-shrink-0"
>
{p?.completada
? <CheckCircle2 className="h-5 w-5 text-success" />
: <Circle className={`h-5 w-5 ${atrasada ? 'text-destructive' : 'text-muted-foreground'}`} />}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-sm font-medium ${p?.completada ? 'line-through text-muted-foreground' : ''}`}>
{t.nombre}
</span>
{t.soloSupervisorCompleta && (
<span className="text-[10px] uppercase bg-amber-100 dark:bg-amber-900/40 text-amber-800 dark:text-amber-200 rounded px-1.5 py-0.5">
Supervisor
</span>
)}
{atrasada && (
<span className="text-[10px] uppercase bg-destructive/10 text-destructive rounded px-1.5 py-0.5">
Atrasada
</span>
)}
</div>
{t.descripcion && (
<p className="text-xs text-muted-foreground truncate">{t.descripcion}</p>
)}
<p className="text-xs text-muted-foreground mt-0.5">
{recurrenciaLabel} · {cuandoLabel}
{fl && ` · vence ${fl.toLocaleDateString('es-MX', { day: 'numeric', month: 'short' })}`}
</p>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<Button variant="ghost" size="icon" onClick={() => handleEdit(t)} title="Editar">
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost" size="icon"
onClick={() => confirm(`¿Eliminar tarea "${t.nombre}"?`) && deleteMutation.mutate(t.id)}
title="Eliminar"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
<Dialog open={showForm} onOpenChange={setShowForm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingId ? 'Editar tarea' : 'Nueva tarea'}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div>
<Label>Nombre</Label>
<Input value={form.nombre} onChange={e => setForm(f => ({ ...f, nombre: e.target.value }))} />
</div>
<div>
<Label>Descripción (opcional)</Label>
<Input value={form.descripcion} onChange={e => setForm(f => ({ ...f, descripcion: e.target.value }))} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Recurrencia</Label>
<select
className="w-full h-10 rounded-md border bg-background px-3 text-sm"
value={form.recurrencia}
onChange={e => setForm(f => ({ ...f, recurrencia: e.target.value }))}
>
{RECURRENCIAS.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
</div>
<div>
<Label>{isWeekly ? 'Día de la semana' : 'Día del mes'}</Label>
{isWeekly ? (
<select
className="w-full h-10 rounded-md border bg-background px-3 text-sm"
value={form.diaSemana}
onChange={e => setForm(f => ({ ...f, diaSemana: parseInt(e.target.value, 10) }))}
>
{DIAS_SEMANA.map(d => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
) : (
<Input
type="number" min={1} max={31}
value={form.diaMes}
onChange={e => setForm(f => ({ ...f, diaMes: parseInt(e.target.value, 10) || 1 }))}
/>
)}
</div>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={form.soloSupervisorCompleta}
onChange={e => setForm(f => ({ ...f, soloSupervisorCompleta: e.target.checked }))}
/>
Solo supervisor/owner pueden marcarla como completada
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowForm(false)}>Cancelar</Button>
<Button onClick={() => saveMutation.mutate()} disabled={!form.nombre || saveMutation.isPending}>
{editingId ? 'Guardar' : 'Crear'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,190 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/auth-store';
/**
* Onboarding persistence key.
* If you later want this to come from env/config, move it to apps/web/config/onboarding.ts
*/
const STORAGE_KEY = 'horux360:onboarding_seen_v1';
export default function OnboardingScreen() {
const router = useRouter();
const { isAuthenticated, _hasHydrated } = useAuthStore();
const [isNewUser, setIsNewUser] = useState(true);
const [loading, setLoading] = useState(false);
const safePush = (path: string) => {
// Avoid multiple navigations if user clicks quickly.
if (loading) return;
setLoading(true);
router.push(path);
};
// Redirect to login if not authenticated
useEffect(() => {
if (_hasHydrated && !isAuthenticated) {
router.push('/login');
}
}, [isAuthenticated, _hasHydrated, router]);
useEffect(() => {
const seen = typeof window !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1';
// If the user has already seen onboarding, go to dashboard automatically.
if (seen) {
setIsNewUser(false);
setLoading(true);
const t = setTimeout(() => router.push('/dashboard'), 900);
return () => clearTimeout(t);
}
}, [router]);
const handleContinue = () => {
if (typeof window !== 'undefined') localStorage.setItem(STORAGE_KEY, '1');
setLoading(true);
setTimeout(() => router.push('/dashboard'), 700);
};
const handleReset = () => {
if (typeof window !== 'undefined') localStorage.removeItem(STORAGE_KEY);
location.reload();
};
const headerStatus = useMemo(() => (isNewUser ? 'Onboarding' : 'Redirección'), [isNewUser]);
// Show loading while store hydrates
if (!_hasHydrated) {
return (
<div className="min-h-screen flex items-center justify-center bg-white">
<div className="animate-pulse text-slate-500">Cargando...</div>
</div>
);
}
// Don't render if not authenticated
if (!isAuthenticated) {
return null;
}
return (
<main className="min-h-screen relative overflow-hidden bg-white">
{/* Grid tech claro */}
<div
className="absolute inset-0 opacity-[0.05]"
style={{
backgroundImage:
'linear-gradient(to right, rgba(15,23,42,.2) 1px, transparent 1px), linear-gradient(to bottom, rgba(15,23,42,.2) 1px, transparent 1px)',
backgroundSize: '48px 48px',
}}
/>
{/* Glow global azul (sutil) */}
<div className="absolute -top-24 left-1/2 h-72 w-[42rem] -translate-x-1/2 rounded-full bg-blue-500/20 blur-3xl" />
<div className="relative z-10 flex min-h-screen items-center justify-center p-6">
<div className="w-full max-w-4xl">
<div className="rounded-2xl border border-slate-200 bg-white shadow-xl overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200">
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-xl bg-blue-600/10 border border-blue-500/30 flex items-center justify-center">
<div className="h-2.5 w-2.5 rounded-full bg-blue-500" />
</div>
<div className="leading-tight">
<p className="text-sm font-semibold text-slate-800">Horux360</p>
<p className="text-xs text-slate-500">Pantalla de inicio</p>
</div>
</div>
<span className="text-xs text-slate-500">{headerStatus}</span>
</div>
{/* Body */}
<div className="p-6 md:p-8">
{isNewUser ? (
<div className="grid gap-8 md:grid-cols-2 md:items-center">
{/* Left */}
<div>
<h1 className="text-2xl md:text-3xl font-semibold text-slate-900">
Bienvenido a Horux360
</h1>
<p className="mt-2 text-sm md:text-base text-slate-600 max-w-md">
Revisa este breve video para conocer el flujo. Después podrás continuar.
</p>
<div className="mt-6 flex items-center gap-3">
<button
onClick={handleContinue}
disabled={loading}
className="bg-blue-600 disabled:opacity-60 disabled:cursor-not-allowed text-white px-6 py-3 rounded-xl font-semibold shadow-md hover:bg-blue-700 hover:shadow-lg transition-all"
>
{loading ? 'Cargando…' : 'Continuar'}
</button>
<button
onClick={() => safePush('/login')}
disabled={loading}
className="px-5 py-3 rounded-xl font-medium text-slate-700 border border-slate-300 hover:bg-slate-100 transition disabled:opacity-60 disabled:cursor-not-allowed"
>
Ver más
</button>
</div>
<div className="mt-6 text-xs text-slate-500">
Usuario nuevo: muestra video Usuario recurrente: redirección automática
</div>
</div>
{/* Right (video) - elegante sin glow */}
<div className="relative">
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<div className="h-1 w-full rounded-t-2xl bg-gradient-to-r from-blue-600/80 via-blue-500/40 to-transparent" />
<div className="p-3">
<div className="rounded-xl border border-slate-200 bg-slate-50 overflow-hidden">
<video src="/video-intro.mp4" controls className="w-full rounded-xl" />
</div>
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">
<span className="flex items-center gap-2">
<span className="inline-block h-2 w-2 rounded-full bg-blue-500" />
Video introductorio
</span>
<span>v1</span>
</div>
</div>
</div>
</div>
</div>
) : (
<div className="py-12 flex flex-col items-center justify-center text-center">
<div className="h-12 w-12 rounded-2xl bg-blue-600/10 border border-blue-500/30 flex items-center justify-center">
<div className="h-3 w-3 rounded-full bg-blue-500 animate-pulse" />
</div>
<h2 className="mt-5 text-lg font-semibold text-slate-800">
Redirigiendo al dashboard
</h2>
<p className="mt-2 text-sm text-slate-600">Usuario recurrente detectado.</p>
<div className="mt-6 w-full max-w-sm h-2 rounded-full bg-slate-200 overflow-hidden border border-slate-300">
<div className="h-full w-2/3 bg-blue-600/80 animate-pulse" />
</div>
<button
onClick={handleReset}
className="mt-6 text-xs text-slate-500 hover:text-slate-700 underline underline-offset-4"
>
Ver video otra vez (reset demo)
</button>
</div>
)}
</div>
</div>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,20 @@
'use client';
import { PeriodSelector } from '@horux/shared-ui';
import { usePeriodoStore } from '@/stores/periodo-store';
/**
* Wrapper alrededor de `<PeriodSelector />` de shared-ui que persiste la
* selección en `periodo-store`. Pasado como children al `<Header>` en las
* páginas que lo usan (mismo patrón que /dashboard, /impuestos, etc).
*/
export function PeriodoSelector() {
const { fechaInicio, fechaFin, setRango } = usePeriodoStore();
return (
<PeriodSelector
fechaInicio={fechaInicio}
fechaFin={fechaFin}
onChange={setRango}
/>
);
}

View File

@@ -0,0 +1,22 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
refetchOnWindowFocus: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

View File

@@ -0,0 +1,26 @@
'use client';
import { useEffect } from 'react';
import { useThemeStore } from '@/stores/theme-store';
import { themes } from '@/themes';
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const { theme } = useThemeStore();
useEffect(() => {
const selectedTheme = themes[theme];
const root = document.documentElement;
Object.entries(selectedTheme.cssVars).forEach(([key, value]) => {
root.style.setProperty(key, value);
});
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
}, [theme]);
return <>{children}</>;
}

View File

@@ -0,0 +1,138 @@
'use client';
import { useState, useCallback } from 'react';
import { Button, Input, Label, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@horux/shared-ui';
import { uploadFiel } from '@/lib/api/fiel';
import type { FielStatus } from '@horux/shared';
interface FielUploadModalProps {
onSuccess: (status: FielStatus) => void;
onClose: () => void;
contribuyenteId?: string | null;
}
export function FielUploadModal({ onSuccess, onClose, contribuyenteId }: FielUploadModalProps) {
const [cerFile, setCerFile] = useState<File | null>(null);
const [keyFile, setKeyFile] = useState<File | null>(null);
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const fileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
const result = reader.result as string;
// Remove data URL prefix (e.g., "data:application/x-x509-ca-cert;base64,")
const base64 = result.split(',')[1];
resolve(base64);
};
reader.onerror = reject;
});
};
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!cerFile || !keyFile || !password) {
setError('Todos los campos son requeridos');
return;
}
setLoading(true);
try {
const cerBase64 = await fileToBase64(cerFile);
const keyBase64 = await fileToBase64(keyFile);
const result = await uploadFiel({
cerFile: cerBase64,
keyFile: keyBase64,
password,
}, contribuyenteId);
if (result.status) {
onSuccess(result.status);
}
} catch (err: any) {
const msg = err.response?.data?.message || err.response?.data?.error || err.message || 'Error al subir la FIEL';
console.error('[FIEL Upload Frontend]', msg, err.response?.data);
setError(msg);
} finally {
setLoading(false);
}
}, [cerFile, keyFile, password, onSuccess]);
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<Card className="w-full max-w-md mx-4">
<CardHeader>
<CardTitle>Configurar FIEL (e.firma)</CardTitle>
<CardDescription>
Sube tu certificado y llave privada para sincronizar CFDIs con el SAT
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="cer">Certificado (.cer)</Label>
<Input
id="cer"
type="file"
accept=".cer"
onChange={(e) => setCerFile(e.target.files?.[0] || null)}
className="cursor-pointer"
/>
</div>
<div className="space-y-2">
<Label htmlFor="key">Llave Privada (.key)</Label>
<Input
id="key"
type="file"
accept=".key"
onChange={(e) => setKeyFile(e.target.files?.[0] || null)}
className="cursor-pointer"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Contrasena de la llave</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Ingresa la contrasena de tu FIEL"
/>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={onClose}
className="flex-1"
>
Cancelar
</Button>
<Button
type="submit"
disabled={loading}
className="flex-1"
>
{loading ? 'Subiendo...' : 'Configurar FIEL'}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,182 @@
'use client';
import { useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Button } from '@horux/shared-ui';
import { getSyncHistory, retrySync } from '@/lib/api/sat';
import type { SatSyncJob } from '@horux/shared';
interface SyncHistoryProps {
fielConfigured: boolean;
contribuyenteId?: string | null;
}
const statusLabels: Record<string, string> = {
pending: 'Pendiente',
running: 'En progreso',
completed: 'Completado',
failed: 'Fallido',
};
const statusColors: Record<string, string> = {
pending: 'bg-yellow-100 text-yellow-800',
running: 'bg-blue-100 text-blue-800',
completed: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
};
const typeLabels: Record<string, string> = {
initial: 'Inicial',
daily: 'Diaria',
};
export function SyncHistory({ fielConfigured, contribuyenteId }: SyncHistoryProps) {
const [jobs, setJobs] = useState<SatSyncJob[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const limit = 10;
const fetchHistory = async () => {
try {
const data = await getSyncHistory(page, limit, contribuyenteId);
setJobs(data.jobs);
setTotal(data.total);
} catch (err) {
console.error('Error fetching sync history:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (fielConfigured) {
fetchHistory();
} else {
setLoading(false);
}
}, [fielConfigured, page, contribuyenteId]);
const handleRetry = async (jobId: string) => {
try {
await retrySync(jobId);
fetchHistory();
} catch (err) {
console.error('Error retrying job:', err);
}
};
if (!fielConfigured) {
return null;
}
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle>Historial de Sincronizaciones</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">Cargando historial...</p>
</CardContent>
</Card>
);
}
if (jobs.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Historial de Sincronizaciones</CardTitle>
<CardDescription>
Registro de todas las sincronizaciones con el SAT
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">No hay sincronizaciones registradas.</p>
</CardContent>
</Card>
);
}
const totalPages = Math.ceil(total / limit);
return (
<Card>
<CardHeader>
<CardTitle>Historial de Sincronizaciones</CardTitle>
<CardDescription>
Registro de todas las sincronizaciones con el SAT
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{jobs.map((job) => (
<div
key={job.id}
className="flex items-center justify-between p-4 border rounded-lg"
>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className={`px-2 py-0.5 rounded text-xs ${statusColors[job.status]}`}>
{statusLabels[job.status]}
</span>
<span className="text-xs text-muted-foreground">
{typeLabels[job.type]}
</span>
</div>
<p className="text-sm">
{job.startedAt ? new Date(job.startedAt).toLocaleString('es-MX') : 'No iniciado'}
</p>
<p className="text-xs text-muted-foreground">
{job.cfdisInserted} nuevos, {job.cfdisUpdated} actualizados
</p>
{job.errorMessage && (
<p className="text-xs text-red-500 mt-1">{job.errorMessage}</p>
)}
</div>
{job.status === 'failed' && (
<Button
size="sm"
variant="outline"
onClick={() => handleRetry(job.id)}
>
Reintentar
</Button>
)}
{job.status === 'running' && (
<div className="text-right">
<p className="text-sm font-medium">{job.progressPercent}%</p>
<p className="text-xs text-muted-foreground">{job.cfdisDownloaded} descargados</p>
</div>
)}
</div>
))}
</div>
{totalPages > 1 && (
<div className="flex justify-center gap-2 mt-4">
<Button
size="sm"
variant="outline"
disabled={page === 1}
onClick={() => setPage(p => p - 1)}
>
Anterior
</Button>
<span className="py-2 px-3 text-sm">
Pagina {page} de {totalPages}
</span>
<Button
size="sm"
variant="outline"
disabled={page === totalPages}
onClick={() => setPage(p => p + 1)}
>
Siguiente
</Button>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,251 @@
'use client';
import { useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Button, Input, Label } from '@horux/shared-ui';
import { getSyncStatus, startSync } from '@/lib/api/sat';
import type { SatSyncStatusResponse } from '@horux/shared';
interface SyncStatusProps {
fielConfigured: boolean;
onSyncStarted?: () => void;
contribuyenteId?: string | null;
}
const statusLabels: Record<string, string> = {
pending: 'Pendiente',
running: 'En progreso',
completed: 'Completado',
failed: 'Fallido',
};
const statusColors: Record<string, string> = {
pending: 'bg-yellow-100 text-yellow-800',
running: 'bg-blue-100 text-blue-800',
completed: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
};
export function SyncStatus({ fielConfigured, onSyncStarted, contribuyenteId }: SyncStatusProps) {
const [status, setStatus] = useState<SatSyncStatusResponse | null>(null);
const [loading, setLoading] = useState(true);
const [startingSync, setStartingSync] = useState(false);
const [error, setError] = useState('');
const [showCustomDate, setShowCustomDate] = useState(false);
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const fetchStatus = async () => {
try {
const data = await getSyncStatus(contribuyenteId);
setStatus(data);
} catch (err) {
console.error('Error fetching sync status:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (fielConfigured) {
fetchStatus();
// Actualizar cada 30 segundos si hay sync activo
const interval = setInterval(fetchStatus, 30000);
return () => clearInterval(interval);
} else {
setLoading(false);
}
}, [fielConfigured, contribuyenteId]);
const handleStartSync = async (type: 'initial' | 'daily', customDates?: boolean) => {
setStartingSync(true);
setError('');
try {
const params: { type: 'initial' | 'daily'; dateFrom?: string; dateTo?: string } = { type };
if (customDates && dateFrom && dateTo) {
// Convertir a formato completo con hora
params.dateFrom = `${dateFrom}T00:00:00`;
params.dateTo = `${dateTo}T23:59:59`;
}
await startSync(params, contribuyenteId);
await fetchStatus();
setShowCustomDate(false);
onSyncStarted?.();
} catch (err: any) {
setError(err.response?.data?.error || 'Error al iniciar sincronizacion');
} finally {
setStartingSync(false);
}
};
if (!fielConfigured) {
return (
<Card>
<CardHeader>
<CardTitle>Sincronizacion SAT</CardTitle>
<CardDescription>
Configura tu FIEL para habilitar la sincronizacion automatica
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
La sincronizacion con el SAT requiere una FIEL valida configurada.
</p>
</CardContent>
</Card>
);
}
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle>Sincronizacion SAT</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">Cargando estado...</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Sincronizacion SAT</CardTitle>
<CardDescription>
Estado de la sincronizacion automatica de CFDIs
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{status?.hasActiveSync && status.currentJob && (
<div className="p-4 bg-blue-50 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-1 rounded text-sm ${statusColors[status.currentJob.status]}`}>
{statusLabels[status.currentJob.status]}
</span>
<span className="text-sm text-muted-foreground">
{status.currentJob.type === 'initial' ? 'Sincronizacion inicial' : 'Sincronizacion diaria'}
</span>
</div>
{status.currentJob.status === 'running' && (
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{ width: `${status.currentJob.progressPercent}%` }}
/>
</div>
)}
<p className="text-sm mt-2">
{status.currentJob.cfdisDownloaded} CFDIs descargados
</p>
</div>
)}
{status?.lastCompletedJob && !status.hasActiveSync && (
<div className="p-4 bg-green-50 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-1 rounded text-sm ${statusColors.completed}`}>
Ultima sincronizacion exitosa
</span>
</div>
<p className="text-sm">
{new Date(status.lastCompletedJob.completedAt!).toLocaleString('es-MX')}
</p>
<p className="text-sm text-muted-foreground">
{status.lastCompletedJob.cfdisInserted} CFDIs nuevos, {status.lastCompletedJob.cfdisUpdated} actualizados
</p>
</div>
)}
<div className="grid grid-cols-2 gap-4 text-center">
<div className="p-4 bg-gray-50 rounded-lg">
<p className="text-2xl font-bold">{status?.totalCfdisSynced || 0}</p>
<p className="text-sm text-muted-foreground">CFDIs sincronizados</p>
</div>
<div className="p-4 bg-gray-50 rounded-lg">
<p className="text-2xl font-bold">3:00 AM</p>
<p className="text-sm text-muted-foreground">Sincronizacion diaria</p>
</div>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
{/* Formulario de fechas personalizadas */}
{showCustomDate && (
<div className="p-4 bg-gray-50 rounded-lg space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="dateFrom">Fecha inicio</Label>
<Input
id="dateFrom"
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
max={dateTo || undefined}
/>
</div>
<div>
<Label htmlFor="dateTo">Fecha fin</Label>
<Input
id="dateTo"
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
min={dateFrom || undefined}
/>
</div>
</div>
<div className="flex gap-2">
<Button
disabled={startingSync || status?.hasActiveSync || !dateFrom || !dateTo}
onClick={() => handleStartSync('initial', true)}
className="flex-1"
>
{startingSync ? 'Iniciando...' : 'Sincronizar periodo'}
</Button>
<Button
variant="outline"
onClick={() => setShowCustomDate(false)}
>
Cancelar
</Button>
</div>
</div>
)}
<div className="flex gap-3">
<Button
variant="outline"
disabled={startingSync || status?.hasActiveSync}
onClick={() => handleStartSync('daily')}
className="flex-1"
>
{startingSync ? 'Iniciando...' : 'Sincronizar mes actual'}
</Button>
<Button
variant="outline"
disabled={startingSync || status?.hasActiveSync}
onClick={() => setShowCustomDate(!showCustomDate)}
className="flex-1"
>
Periodo personalizado
</Button>
</div>
{!status?.lastCompletedJob && (
<Button
disabled={startingSync || status?.hasActiveSync}
onClick={() => handleStartSync('initial')}
className="w-full"
>
{startingSync ? 'Iniciando...' : 'Sincronizacion inicial (6 años)'}
</Button>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,158 @@
'use client';
import { useState, useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { getTenants, type Tenant } from '@/lib/api/tenants';
import { useTenantViewStore } from '@/stores/tenant-view-store';
import { useAuthStore } from '@/stores/auth-store';
import { Building, ChevronDown, Check, X } from 'lucide-react';
import { cn } from '@horux/shared-ui';
import { isGlobalAdminRfc } from '@horux/shared';
export function TenantSelector() {
const [open, setOpen] = useState(false);
const { user } = useAuthStore();
const queryClient = useQueryClient();
const { viewingTenantId, viewingTenantName, setViewingTenant, clearViewingTenant } = useTenantViewStore();
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
const { data: tenants, isLoading } = useQuery({
queryKey: ['tenants'],
queryFn: getTenants,
enabled: isGlobalAdmin,
});
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest('.tenant-selector')) {
setOpen(false);
}
};
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}, []);
// Solo admin global — ningún otro admin puede cambiar de tenant
if (!isGlobalAdmin) {
return null;
}
const currentTenant = viewingTenantId
? tenants?.find(t => t.id === viewingTenantId)
: null;
const displayName = viewingTenantName || currentTenant?.nombre || user?.tenantName;
const isViewingOther = viewingTenantId && viewingTenantId !== user?.tenantId;
return (
<div className="tenant-selector relative">
<button
onClick={() => setOpen(!open)}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isViewingOther
? 'bg-primary/10 text-primary border border-primary/30'
: 'hover:bg-accent'
)}
>
<Building className="h-4 w-4" />
<span className="max-w-[150px] truncate">{displayName}</span>
{isViewingOther && (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
clearViewingTenant();
queryClient.invalidateQueries();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
clearViewingTenant();
queryClient.invalidateQueries();
}
}}
className="ml-1 p-0.5 rounded hover:bg-primary/20 cursor-pointer"
title="Volver a mi empresa"
>
<X className="h-3 w-3" />
</span>
)}
<ChevronDown className={cn('h-4 w-4 transition-transform', open && 'rotate-180')} />
</button>
{open && (
<div className="absolute top-full left-0 mt-2 w-72 rounded-lg border bg-card shadow-lg z-50">
<div className="p-2 border-b">
<p className="text-xs text-muted-foreground px-2">Seleccionar cliente</p>
</div>
<div className="max-h-64 overflow-y-auto p-1">
{isLoading ? (
<div className="px-3 py-2 text-sm text-muted-foreground">Cargando...</div>
) : tenants && tenants.length > 0 ? (
<>
{/* Option to go back to own tenant */}
<button
onClick={() => {
clearViewingTenant();
setOpen(false);
queryClient.invalidateQueries();
}}
className={cn(
'flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors',
!viewingTenantId && 'bg-primary/10'
)}
>
<div className="h-8 w-8 rounded bg-primary/10 flex items-center justify-center">
<Building className="h-4 w-4 text-primary" />
</div>
<div className="flex-1 text-left">
<p className="font-medium">{user?.tenantName}</p>
<p className="text-xs text-muted-foreground">Mi empresa</p>
</div>
{!viewingTenantId && <Check className="h-4 w-4 text-primary" />}
</button>
<div className="my-1 border-t" />
{/* Other tenants */}
{tenants
.filter(t => t.id !== user?.tenantId)
.map((tenant) => (
<button
key={tenant.id}
onClick={() => {
setViewingTenant(tenant.id, tenant.nombre, tenant.rfc);
setOpen(false);
queryClient.invalidateQueries();
}}
className={cn(
'flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors',
viewingTenantId === tenant.id && 'bg-primary/10'
)}
>
<div className="h-8 w-8 rounded bg-muted flex items-center justify-center text-xs font-medium">
{tenant.nombre.substring(0, 2).toUpperCase()}
</div>
<div className="flex-1 text-left">
<p className="font-medium truncate">{tenant.nombre}</p>
<p className="text-xs text-muted-foreground">{tenant.rfc}</p>
</div>
{viewingTenantId === tenant.id && <Check className="h-4 w-4 text-primary" />}
</button>
))}
</>
) : (
<div className="px-3 py-2 text-sm text-muted-foreground">
No hay otros clientes
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,300 @@
// AUTO-GENERADO por `pnpm legal:sync`. NO editar a mano.
// Fuente: docs/legal/Terminos y condiciones.pdf
// Regenerar tras actualizar el PDF.
export const TERMINOS_TEXT = `Términos y Condiciones plataforma Horux 360
Vigentes a partir de: 15 de abril de 2026
1. Definiciones
Para los efectos de los presentes Términos y Condiciones (los "Términos") "Horux 360" o "la
Plataforma": el servicio en línea de software como servicio (SaaS) operado por HORUX 360
S.A. DE C.V. con RFC HTS240708LJA y domicilio en Florencia 2280, Italia providencia,
Guadalajara, Jalisco, México. Disponible en https://horuxfin.com.
-"Usuario": la persona física o moral, mayor de edad, con capacidad legal, que accede o
utiliza la Plataforma.
- "Cuenta": el registro único asociado a un correo electrónico que da acceso a la Plataforma.
- "Empresa Cliente": persona física o moral inscrita en el RFC del SAT que el Usuario registra
como tenant en la Plataforma.
- "Datos Fiscales": información de naturaleza fiscal, contable y financiera del Usuario o sus
Empresas Cliente, incluyendo CFDIs, declaraciones, pagos, registros contables, etc.
- "FIEL/e.firma": certificado de Firma Electrónica Avanzada emitido por el SAT.
- "CSD": Certificado de Sello Digital emitido por el SAT.
- "Suscripción": el plan contratado (starter, business, business_ia, custom, enterprise) con
su periodicidad (mensual o anual).
2. Aceptación
El uso de la Plataforma implica la aceptación expresa, incondicional y sin reserva de los
presentes Términos. Si el Usuario no está de acuerdo con cualquier disposición, debe
abstenerse de utilizar la Plataforma. La aceptación se materializa al hacer clic en el botón
de registro o al utilizar cualquier funcionalidad de la Plataforma.
3. Objeto
Horux 360 ofrece servicios tecnológicos de:
• Análisis y consulta de Comprobantes Fiscales Digitales por Internet (CFDIs).
-- 1 of 10 --
• Cálculos estimados de impuestos (IVA, ISR) basados en información cargada por el
Usuario o sincronizada con el SAT.
• Sincronización con el portal del SAT mediante FIEL/e.firma proporcionada por el
Usuario.
• Emisión de facturas electrónicas (CFDIs) a través de PAC autorizado tercero
(Facturapi).
• Generación de reportes financieros (estado de resultados, flujo de efectivo,
conciliación, etc.).
• Almacenamiento de información fiscal del Usuario.
• Otras funcionalidades conexas que Horux 360 incorpore en el futuro.
4. Naturaleza del servicio — Disclaimer fiscal
LA PLATAFORMA ES UNA HERRAMIENTA TECNOLÓGICA, NO UN DESPACHO CONTABLE NI
ASESOR FISCAL.
4.1. Los cálculos, reportes, alertas y cifras presentadas en la Plataforma son estimaciones
automáticas generadas con base en:
• La información que el Usuario carga, sube o sincroniza.
• Reglas fiscales generales vigentes al momento del cálculo.
• Algoritmos predeterminados que NO consideran la totalidad de circunstancias
particulares del Usuario.
4.2. El Usuario reconoce y acepta que:
• Los cálculos son estimados y NO sustituyen el dictamen, asesoría o trabajo de un
Contador Público Certificado o asesor fiscal.
• Las cifras pueden discrepar de las determinadas oficialmente por el SAT.
• Es responsabilidad exclusiva del Usuario validar todas las cifras antes de
presentarlas en declaraciones, documentos oficiales o ante autoridades fiscales.
• Horux 360 NO presenta declaraciones, NO realiza pagos provisionales, NO sustituye
obligaciones fiscales del Usuario.
• Cualquier multa, recargo, accesorio, sanción administrativa, fiscal o penal derivada
del uso o mal uso de la información de la Plataforma es **responsabilidad exclusiva
del Usuario**.
5. Cuenta de usuario
-- 2 of 10 --
5.1. El Usuario debe proporcionar información veraz, completa y actualizada al registrarse.
5.2. El Usuario es responsable de mantener la confidencialidad de su contraseña y de todas
las actividades realizadas desde su Cuenta.
5.3. El Usuario debe notificar inmediatamente a Horux 360 cualquier uso no autorizado de
su Cuenta o brecha de seguridad detectada.
5.4. Horux 360 puede suspender o cancelar Cuentas que:
• Proporcionen información falsa.
• Violen los presentes Términos.
• Realicen actividades fraudulentas, ilegales o que afecten el funcionamiento de la
Plataforma.
• Mantengan adeudos por más de 30 días naturales en su Suscripción.
5.5. El Usuario podrá tener acceso a múltiples Empresas Cliente bajo una misma Cuenta.
Cada Empresa Cliente requerirá su propia Suscripción salvo lo establecido en el plan
contratado.
6. Suscripciones, precios y pagos
6.1. Planes y precios: los planes vigentes y sus precios se encuentran publicados en la
Plataforma. Horux 360 se reserva el derecho de modificar precios con 30 días naturales de
aviso al Usuario. Cambios solo aplican a periodos de facturación posteriores al aviso.
6.2. Prueba gratuita: se ofrece una única prueba gratuita de 30 días por RFC y por Usuario
propietario, sin requerir tarjeta de crédito. Una vez agotada, el Usuario debe contratar un
plan de pago para continuar usando la Plataforma.
6.3. Procesador de pagos: los pagos se procesan a través de MercadoPago. Horux 360 NO
almacena información de tarjetas. La relación con MercadoPago se rige por sus propios
términos.
6.4. Renovación automática: las Suscripciones se renuevan automáticamente al final de
cada periodo. El Usuario puede cancelar en cualquier momento desde su panel.
6.5. Cancelación: al cancelar, el Usuario conserva acceso hasta el fin del periodo pagado.
Posteriormente la Cuenta entra en modo lectura por 60 días naturales y luego puede ser
desactivada. NO hay reembolsos por cancelación parcial de un periodo.
-- 3 of 10 --
6.6. Mora: el incumplimiento de pago por más de 30 días naturales faculta a Horux 360 para
suspender el acceso. Los datos fiscales del Usuario se conservarán por 90 días adicionales
antes de su eliminación, salvo obligación legal de retención.
6.7. Facturación: Horux 360 emitirá CFDI por el cobro de la Suscripción al Usuario. Los datos
fiscales para facturación deben proporcionarse oportunamente; sin ellos, se emitirá CFDI
al público en general.
7. FIEL, CSD y datos fiscales sensibles
7.1. Custodia voluntaria: el Usuario puede, opcionalmente, cargar su FIEL/e.firma y CSD en
la Plataforma para habilitar las funciones de sincronización SAT y emisión de CFDIs
respectivamente.
7.2. Medidas de seguridad: Horux 360 almacena estos archivos cifrados con AES-256-GCM
y solo los descifra en memoria al momento de uso. La contraseña asociada nunca se
persiste en disco sin cifrado.
7.3. Responsabilidad del Usuario:
• Es responsabilidad exclusiva del Usuario mantener la vigencia, validez y revocación
oportuna de su FIEL/CSD ante el SAT.
• El Usuario reconoce que la FIEL es un instrumento de firma con efectos legales
equivalentes a su firma autógrafa, y que su uso desde la Plataforma queda bajo su
responsabilidad.
• Si el Usuario detecta uso indebido de su FIEL/CSD, debe revocarlo inmediatamente
ante el SAT y notificar a Horux 360.
7.4. Limitación: Horux 360 utiliza la FIEL/CSD exclusivamente para los fines autorizados por
el Usuario (sincronización SAT, emisión CFDI). NO realiza ningún acto en nombre del
Usuario fuera de las acciones explícitamente solicitadas mediante la interfaz.
7.5. Eliminación: el Usuario puede eliminar su FIEL/CSD en cualquier momento desde el
panel. Al cancelar la Suscripción, los archivos se eliminan automáticamente.
8. Protección de datos personales
8.1. Horux 360 trata los datos personales del Usuario y de terceros relacionados (clientes,
proveedores, empleados) conforme a la Ley Federal de Protección de Datos Personales en
Posesión de los Particulares (LFPDPPP) y su Reglamento.
-- 4 of 10 --
8.2. El Aviso de Privacidad integral está disponible en https://horuxfin.com/privacidad y
forma parte integrante de los presentes Términos.
8.3. Tratamiento de datos de terceros: la información de RFCs, razones sociales, montos y
demás datos contenidos en CFDIs incluye datos personales de terceros (clientes,
proveedores). El Usuario manifiesta que cuenta con las autorizaciones, consentimientos y
bases legales necesarias para tratar dichos datos a través de la Plataforma, conforme a las
finalidades para las que fueron recabados originalmente.
8.4. Encargo de datos: Horux 360 actúa como encargado del tratamiento respecto a los
datos fiscales del Usuario, quien funge como responsable. El Usuario instruye a Horux 360
las finalidades del tratamiento mediante el uso de la Plataforma.
8.5. Derechos ARCO: el Usuario puede ejercer sus derechos de Acceso, Rectificación,
Cancelación y Oposición conforme se establece en el Aviso de Privacidad.
8.6. Transferencias: Horux 360 puede transferir datos a proveedores tecnológicos
necesarios para la operación de la Plataforma (Facturapi para emisión CFDI, MercadoPago
para procesamiento de pagos, hosting cloud). Estas transferencias se realizan con
cláusulas de confidencialidad equivalentes a las aquí establecidas.
## 9. Disponibilidad del servicio
9.1. Horux 360 procurará mantener una disponibilidad mensual del 85%. Sin embargo, no
garantiza disponibilidad ininterrumpida del servicio.
9.2. El servicio puede ser suspendido por:
• Mantenimiento programado (notificado con al menos 24 horas de anticipación
cuando sea posible).
• Mantenimiento de emergencia.
• Caso fortuito o fuerza mayor.
• Fallas de proveedores tecnológicos terceros (SAT, MercadoPago, Facturapi, hosting
cloud).
• Causas ajenas al control de Horux 360.
9.3. La indisponibilidad temporal del servicio NO da derecho a reembolsos, descuentos o
compensaciones, salvo que la indisponibilidad continua exceda 72 horas consecutivas
atribuibles a Horux 360 (excluyendo terceros), en cuyo caso se otorgará crédito proporcional
aplicable al siguiente periodo.
-- 5 of 10 --
10. Limitación de responsabilidad
10.1. Hasta donde lo permita la ley aplicable, la responsabilidad total acumulada de Horux
360 frente al Usuario por cualquier reclamación derivada o relacionada con los presentes
Términos o el uso de la Plataforma estará limitada al monto efectivamente pagado por el
Usuario en los 6 meses inmediatos anteriores al evento que originó la reclamación.
10.2. En ningún caso Horux 360 será responsable por:
• Pérdida de utilidades, ingresos, datos o oportunidades de negocio.
• Multas, recargos, sanciones o accesorios fiscales determinados por el SAT u otra
autoridad, derivados de acciones u omisiones del Usuario.
• Cálculos incorrectos derivados de información incompleta, errónea o
desactualizada cargada por el Usuario.
• Decisiones de negocio tomadas con base en cifras o reportes de la Plataforma sin
validación profesional.
• Daños indirectos, incidentales, especiales, consecuenciales o punitivos.
• Actos u omisiones de terceros (SAT, PAC, procesadores de pago, contadores externos
del Usuario).
• Imposibilidad de emitir CFDIs por suspensión, revocación o vencimiento del CSD del
Usuario.
• Discrepancias entre lo reportado en la Plataforma y lo determinado oficialmente por
el SAT.
10.3. Las limitaciones anteriores aplican aun cuando se haya advertido a Horux 360 sobre
la posibilidad de tales daños.
11. Indemnización
El Usuario acepta indemnizar, defender y mantener en paz y a salvo a Horux 360, sus
accionistas, directivos, empleados y filiales, frente a cualquier reclamación, demanda,
daño, pérdida, multa o gasto (incluyendo honorarios razonables de abogados) que se origine
en:
• El uso o mal uso de la Plataforma por parte del Usuario.
• Información proporcionada por el Usuario que sea falsa, incorrecta o incompleta.
• Violación de los presentes Términos por parte del Usuario.
-- 6 of 10 --
• Violación de derechos de terceros (incluyendo derechos de propiedad intelectual o
de privacidad) por información cargada por el Usuario.
• Incumplimiento de las obligaciones fiscales del Usuario.
• Demandas o requerimientos del SAT u otras autoridades dirigidas al Usuario.
12. Propiedad intelectual
12.1. La Plataforma, su código, diseño, marcas, logos, contenido editorial, algoritmos y
demás elementos son propiedad exclusiva de Horux 360 o de sus licenciantes. Están
protegidos por las leyes mexicanas e internacionales de propiedad intelectual.
12.2. Horux 360 otorga al Usuario una licencia no exclusiva, intransferible, revocable y
limitada a la vigencia de su Suscripción para usar la Plataforma conforme a los presentes
Términos.
12.3. El Usuario NO puede:
• Copiar, modificar, distribuir, vender o alquilar la Plataforma.
• Hacer ingeniería inversa, descompilar o intentar extraer el código fuente.
• Acceder a la Plataforma con fines de competencia.
• Usar bots, scrapers o herramientas automatizadas que excedan el uso normal.
• Eliminar o alterar avisos de propiedad intelectual.
12.4. El Usuario conserva todos los derechos sobre la información y datos fiscales que
cargue. Horux 360 NO reclama propiedad sobre dichos datos, los cuales se consideran del
Usuario en todo momento.
13. Modificaciones a los Términos
13.1. Horux 360 se reserva el derecho de modificar los presentes Términos en cualquier
momento. Las modificaciones se notificarán al Usuario por correo electrónico y/o mediante
aviso en la Plataforma con al menos 15 días naturales de anticipación.
13.2. El uso continuo de la Plataforma después de la entrada en vigor de las modificaciones
implica aceptación de estas. Si el Usuario no está de acuerdo, debe cancelar su Suscripción
antes de la fecha de entrada en vigor.
14. Terminación
-- 7 of 10 --
14.1. Cualquiera de las partes puede terminar la relación con o sin causa, mediante aviso
por escrito (correo electrónico es suficiente).
14.2. Horux 360 puede terminar inmediatamente y sin aviso previo en caso de:
• Violación material de los Términos.
• Uso fraudulento o ilegal de la Plataforma.
• Mora superior a 30 días naturales.
• Acto que ponga en riesgo la seguridad o disponibilidad de la Plataforma.
14.3. Efectos de la terminación:
• El Usuario perderá acceso a la Plataforma.
• Los datos fiscales se conservarán por 90 días naturales para permitir al Usuario su
descarga, salvo obligación legal de retención.
• Las obligaciones de pago devengadas hasta la fecha de terminación subsisten.
• Las cláusulas relativas a indemnización, limitación de responsabilidad, propiedad
intelectual y jurisdicción sobreviven a la terminación.
15. Conservación y portabilidad de datos
15.1. Horux 360 conserva los datos fiscales del Usuario por el tiempo que dure la
Suscripción y por hasta 5 años posteriores a la terminación, en cumplimiento del **Artículo
30 del Código Fiscal de la Federación** que obliga a contribuyentes a conservar
contabilidad por dicho plazo.
15.2. El Usuario puede solicitar la exportación de sus datos en formato Excel/CSV en
cualquier momento desde el panel.
15.3. Posterior al plazo del numeral 15.1, los datos podrán ser eliminados definitivamente
sin obligación de aviso previo.
16. Notificaciones
16.1. Las notificaciones a Horux 360 deben enviarse a carlos@horuxfin.com con copia
soporte@horuxfin.com.
16.2. Las notificaciones al Usuario se enviarán al correo registrado en su Cuenta. Es
obligación del Usuario mantenerlo actualizado.
-- 8 of 10 --
17. Cesión
17.1. El Usuario NO puede ceder sus derechos u obligaciones bajo los presentes Términos
sin consentimiento previo y por escrito de Horux 360.
17.2. Horux 360 puede ceder los presentes Términos a un sucesor en interés (fusión,
escisión, venta de activos), notificando al Usuario con 30 días naturales de anticipación.
18. Caso fortuito y fuerza mayor
Ninguna de las partes será responsable por incumplimiento debido a caso fortuito o fuerza
mayor, incluyendo sin limitación: desastres naturales, pandemias, actos de gobierno,
guerra, terrorismo, ciberataques masivos, fallas generalizadas de infraestructura de internet
o energía eléctrica, o eventos similares fuera del control razonable de las partes.
19. Independencia de disposiciones
Si cualquier disposición de los presentes Términos es declarada nula, ilegal o inexigible por
autoridad competente, las demás disposiciones permanecerán en pleno vigor y efecto.
20. Acuerdo completo
Los presentes Términos, junto con el Aviso de Privacidad y cualquier acuerdo adicional
firmado entre las partes, constituyen el acuerdo completo entre el Usuario y Horux 360,
dejando sin efecto cualquier acuerdo previo verbal o escrito.
21. Legislación aplicable y jurisdicción
21.1. Los presentes Términos se rigen por las leyes federales de los **Estados Unidos
Mexicanos**.
21.2. Para la interpretación, cumplimiento y ejecución de los presentes Términos, las partes
se someten expresamente a la jurisdicción de los tribunales competentes de Guadalajara,
Jalisco, México, renunciando a cualquier otro fuero que pudiera corresponderles por razón
de su domicilio presente o futuro.
21.3. Antes de iniciar acciones legales, las partes intentarán resolver controversias
mediante negociación directa por al menos 30 días naturales. De no llegar a acuerdo,
-- 9 of 10 --
podrán optar por mediación ante la Procuraduría Federal del Consumidor (PROFECO)
cuando aplique, o acudir a los tribunales referidos.
-- 10 of 10 --`;
export const TERMINOS_META = {
extractedAt: '2026-04-14T22:41:46.559Z',
pages: 10,
chars: 16888,
} as const;

View File

@@ -0,0 +1,37 @@
import { apiClient } from './client';
export interface SubscriptionAddon {
id: string;
codename: string;
nombre: string;
precio: number;
quantity: number;
contribuyenteId: string | null;
status: string;
currentPeriodStart: string | null;
currentPeriodEnd: string | null;
}
export interface AddonsResponse {
subscription: { id: string; plan: string; status: string } | null;
addons: SubscriptionAddon[];
}
export async function listMyAddons(contribuyenteId?: string): Promise<AddonsResponse> {
const params = contribuyenteId ? { contribuyenteId } : undefined;
const { data } = await apiClient.get<AddonsResponse>('/subscriptions/me/addons', { params });
return data;
}
export async function subscribeAddon(params: {
addonCodename: string;
quantity?: number;
contribuyenteId?: string | null;
}): Promise<{ addon: SubscriptionAddon; paymentUrl: string }> {
const { data } = await apiClient.post('/subscriptions/me/addons', params);
return data;
}
export async function cancelAddon(addonId: string): Promise<void> {
await apiClient.delete(`/subscriptions/me/addons/${addonId}`);
}

View File

@@ -0,0 +1,44 @@
import { apiClient } from './client';
export interface ClientesStats {
suscripcionesPorPlan: Array<{ plan: string; count: number }>;
ingresos: { total: number; paymentsCount: number };
noRenovaciones: Array<{
tenantId: string;
tenantNombre: string;
rfc: string;
plan: string;
currentPeriodEnd: string;
statusActual: string;
}>;
usuariosPorCliente: Array<{
tenantId: string;
tenantNombre: string;
rfc: string;
activeUsers: number;
owners: number;
}>;
}
export interface TenantUsuario {
userId: string;
email: string;
nombre: string;
rol: string;
isOwner: boolean;
joinedAt: string;
lastLogin: string | null;
}
export async function getClientesStats(from?: string, to?: string): Promise<ClientesStats> {
const params = new URLSearchParams();
if (from) params.set('from', from);
if (to) params.set('to', to);
const res = await apiClient.get<ClientesStats>(`/admin/clientes/stats?${params}`);
return res.data;
}
export async function getTenantUsuarios(tenantId: string): Promise<TenantUsuario[]> {
const res = await apiClient.get<{ data: TenantUsuario[] }>(`/admin/clientes/${tenantId}/usuarios`);
return res.data.data;
}

View File

@@ -0,0 +1,40 @@
import { apiClient } from './client';
import type { AlertaFull, AlertaCreate, AlertaUpdate, AlertasStats } from '@horux/shared';
export async function getAlertas(filters?: { leida?: boolean; resuelta?: boolean; contribuyenteId?: string }): Promise<AlertaFull[]> {
const params = new URLSearchParams();
if (filters?.leida !== undefined) params.set('leida', String(filters.leida));
if (filters?.resuelta !== undefined) params.set('resuelta', String(filters.resuelta));
if (filters?.contribuyenteId) params.set('contribuyenteId', filters.contribuyenteId);
const response = await apiClient.get<AlertaFull[]>(`/alertas?${params}`);
return response.data;
}
export async function getAlertasAutomaticas(contribuyenteId?: string): Promise<any[]> {
const params = contribuyenteId ? `?contribuyenteId=${encodeURIComponent(contribuyenteId)}` : '';
const response = await apiClient.get<any[]>(`/alertas/automaticas${params}`);
return response.data;
}
export async function getStats(): Promise<AlertasStats> {
const response = await apiClient.get<AlertasStats>('/alertas/stats');
return response.data;
}
export async function createAlerta(data: AlertaCreate): Promise<AlertaFull> {
const response = await apiClient.post<AlertaFull>('/alertas', data);
return response.data;
}
export async function updateAlerta(id: number, data: AlertaUpdate): Promise<AlertaFull> {
const response = await apiClient.patch<AlertaFull>(`/alertas/${id}`, data);
return response.data;
}
export async function deleteAlerta(id: number): Promise<void> {
await apiClient.delete(`/alertas/${id}`);
}
export async function markAllAsRead(): Promise<void> {
await apiClient.post('/alertas/mark-all-read');
}

View File

@@ -0,0 +1,37 @@
import { apiClient } from './client';
export interface AuditLogEntry {
id: string;
userId: string | null;
tenantId: string | null;
action: string;
entityType: string | null;
entityId: string | null;
metadata: Record<string, any> | null;
createdAt: string;
user: { id: string; email: string; nombre: string } | null;
tenant: { id: string; nombre: string; rfc: string } | null;
}
export interface AuditLogFilters {
action?: string;
tenantId?: string;
userId?: string;
from?: string;
to?: string;
page?: number;
limit?: number;
}
export interface AuditLogResponse {
data: AuditLogEntry[];
page: number;
limit: number;
total: number;
totalPages: number;
}
export async function listAuditLog(filters: AuditLogFilters = {}): Promise<AuditLogResponse> {
const response = await apiClient.get<AuditLogResponse>('/audit-log', { params: filters });
return response.data;
}

48
apps/web/lib/api/auth.ts Normal file
View File

@@ -0,0 +1,48 @@
import { apiClient } from './client';
import type { LoginRequest, RegisterRequest, LoginResponse } from '@horux/shared';
export async function login(data: LoginRequest): Promise<LoginResponse> {
const response = await apiClient.post<LoginResponse>('/auth/login', data);
return response.data;
}
export async function register(data: RegisterRequest): Promise<LoginResponse> {
const response = await apiClient.post<LoginResponse>('/auth/register', data);
return response.data;
}
export async function logout(): Promise<void> {
const refreshToken = localStorage.getItem('refreshToken');
await apiClient.post('/auth/logout', { refreshToken });
}
export async function getMe(): Promise<LoginResponse['user']> {
const response = await apiClient.get('/auth/me');
return response.data.user;
}
export async function requestPasswordReset(email: string): Promise<{ message: string }> {
const response = await apiClient.post('/auth/password-reset/request', { email });
return response.data;
}
export async function confirmPasswordReset(token: string, newPassword: string): Promise<{ message: string }> {
const response = await apiClient.post('/auth/password-reset/confirm', { token, newPassword });
return response.data;
}
export async function changePassword(currentPassword: string, newPassword: string): Promise<{ message: string }> {
const response = await apiClient.post('/auth/password-change', { currentPassword, newPassword });
return response.data;
}
export async function logoutAll(): Promise<{ message: string }> {
const response = await apiClient.post('/auth/logout-all');
return response.data;
}
export async function switchTenant(tenantId: string): Promise<LoginResponse> {
const refreshToken = localStorage.getItem('refreshToken') || '';
const response = await apiClient.post<LoginResponse>('/auth/switch-tenant', { tenantId, refreshToken });
return response.data;
}

View File

@@ -0,0 +1,28 @@
import { apiClient } from './client';
export interface Banco {
id: number;
banco: string;
terminacionCuenta: string;
}
export async function getBancos(contribuyenteId?: string | null): Promise<Banco[]> {
const params = new URLSearchParams();
if (contribuyenteId) params.set('contribuyenteId', contribuyenteId);
const res = await apiClient.get<Banco[]>(`/bancos?${params}`);
return res.data;
}
export async function createBanco(data: { banco: string; terminacionCuenta: string; contribuyenteId?: string }): Promise<Banco> {
const res = await apiClient.post<Banco>('/bancos', data);
return res.data;
}
export async function updateBanco(id: number, data: { banco?: string; terminacionCuenta?: string }): Promise<Banco> {
const res = await apiClient.put<Banco>(`/bancos/${id}`, data);
return res.data;
}
export async function deleteBanco(id: number): Promise<void> {
await apiClient.delete(`/bancos/${id}`);
}

View File

@@ -0,0 +1,28 @@
import { apiClient } from './client';
import type { EventoFiscal, EventoCreate, EventoUpdate } from '@horux/shared';
export async function getEventos(año: number, contribuyenteId?: string | null): Promise<EventoFiscal[]> {
const params = new URLSearchParams({ año: año.toString() });
if (contribuyenteId) params.set('contribuyenteId', contribuyenteId);
const response = await apiClient.get<EventoFiscal[]>(`/calendario/generados?${params}`);
return response.data;
}
export async function getProximos(dias = 30): Promise<EventoFiscal[]> {
const response = await apiClient.get<EventoFiscal[]>(`/calendario/proximos?dias=${dias}`);
return response.data;
}
export async function createEvento(data: EventoCreate): Promise<EventoFiscal> {
const response = await apiClient.post<EventoFiscal>('/calendario', data);
return response.data;
}
export async function updateEvento(id: number, data: EventoUpdate): Promise<EventoFiscal> {
const response = await apiClient.patch<EventoFiscal>(`/calendario/${id}`, data);
return response.data;
}
export async function deleteEvento(id: number): Promise<void> {
await apiClient.delete(`/calendario/${id}`);
}

View File

@@ -0,0 +1,73 @@
import { apiClient } from './client';
export interface Cartera {
id: string;
supervisorUserId: string | null;
auxiliarUserId: string | null;
parentId: string | null;
nombre: string;
descripcion: string | null;
createdAt: string;
entidadesCount: number;
subcarterasCount: number;
}
export interface SupervisorOption {
userId: string;
nombre: string;
email: string;
}
export async function getCarteras(): Promise<{ data: Cartera[] }> {
const { data } = await apiClient.get('/carteras');
return data;
}
export async function getSupervisores(): Promise<{ data: SupervisorOption[] }> {
const { data } = await apiClient.get('/carteras/supervisores');
return data;
}
export async function createCartera(payload: { nombre: string; descripcion?: string; supervisorUserId?: string }): Promise<Cartera> {
const { data } = await apiClient.post('/carteras', payload);
return data;
}
export async function updateCartera(id: string, payload: { nombre?: string; descripcion?: string }): Promise<Cartera> {
const { data } = await apiClient.put(`/carteras/${id}`, payload);
return data;
}
export async function deleteCartera(id: string): Promise<void> {
await apiClient.delete(`/carteras/${id}`);
}
export async function getCarteraEntidades(id: string): Promise<{ data: string[] }> {
const { data } = await apiClient.get(`/carteras/${id}/entidades`);
return data;
}
export async function addEntidadToCartera(carteraId: string, entidadId: string): Promise<void> {
await apiClient.post(`/carteras/${carteraId}/entidades`, { entidadId });
}
export async function removeEntidadFromCartera(carteraId: string, entidadId: string): Promise<void> {
await apiClient.delete(`/carteras/${carteraId}/entidades/${entidadId}`);
}
// Subcarteras
export async function getSubcarteras(carteraId: string): Promise<{ data: Cartera[] }> {
const { data } = await apiClient.get(`/carteras/${carteraId}/subcarteras`);
return data;
}
export async function createSubcartera(carteraId: string, payload: { nombre: string; descripcion?: string; auxiliarUserId: string }): Promise<Cartera> {
const { data } = await apiClient.post(`/carteras/${carteraId}/subcarteras`, payload);
return data;
}
// Auxiliares del supervisor (for subcartera assignment)
export async function getAuxiliaresDelSupervisor(supervisorId: string): Promise<{ data: Array<{ auxiliarUserId: string }> }> {
const { data } = await apiClient.get(`/carteras/${supervisorId}/auxiliares-disponibles`);
return data;
}

View File

@@ -0,0 +1,24 @@
import { apiClient } from './client';
export interface CatalogoItem {
id: number;
clave: string;
descripcion: string;
}
export interface UsoCfdiItem extends CatalogoItem {
personaFisica: boolean;
personaMoral: boolean;
}
export interface MonedaItem extends CatalogoItem {
decimales: number;
}
export const getFormasPago = () => apiClient.get<CatalogoItem[]>('/catalogos/forma-pago').then(r => r.data);
export const getMetodosPago = () => apiClient.get<CatalogoItem[]>('/catalogos/metodo-pago').then(r => r.data);
export const getUsosCfdi = () => apiClient.get<UsoCfdiItem[]>('/catalogos/uso-cfdi').then(r => r.data);
export const getMonedas = () => apiClient.get<MonedaItem[]>('/catalogos/moneda').then(r => r.data);
export const getClavesUnidad = () => apiClient.get<CatalogoItem[]>('/catalogos/clave-unidad').then(r => r.data);
export const searchClaveProdServ = (q: string) => apiClient.get<CatalogoItem[]>(`/catalogos/clave-prod-serv?q=${encodeURIComponent(q)}`).then(r => r.data);
export const getObjetosImp = () => apiClient.get<CatalogoItem[]>('/catalogos/objeto-imp').then(r => r.data);

135
apps/web/lib/api/cfdi.ts Normal file
View File

@@ -0,0 +1,135 @@
import { apiClient } from './client';
import type { CfdiListResponse, CfdiFilters, Cfdi } from '@horux/shared';
export async function getCfdis(filters: CfdiFilters): Promise<CfdiListResponse> {
const params = new URLSearchParams();
if (filters.tipo) params.set('tipo', filters.tipo);
if (filters.tipoComprobante) params.set('tipoComprobante', filters.tipoComprobante);
if (filters.estado) params.set('estado', filters.estado);
if (filters.fechaInicio) params.set('fechaInicio', filters.fechaInicio);
if (filters.fechaFin) params.set('fechaFin', filters.fechaFin);
if (filters.rfc) params.set('rfc', filters.rfc);
if (filters.emisor) params.set('emisor', filters.emisor);
if (filters.receptor) params.set('receptor', filters.receptor);
if (filters.search) params.set('search', filters.search);
if (filters.page) params.set('page', filters.page.toString());
if (filters.limit) params.set('limit', filters.limit.toString());
if (filters.contribuyenteId) params.set('contribuyenteId', filters.contribuyenteId);
const response = await apiClient.get<CfdiListResponse>(`/cfdi?${params}`);
return response.data;
}
export async function getCfdiById(id: string): Promise<Cfdi> {
const response = await apiClient.get<Cfdi>(`/cfdi/${id}`);
return response.data;
}
export async function getResumenCfdi(año?: number, mes?: number, contribuyenteId?: string) {
const params = new URLSearchParams();
if (año) params.set('año', año.toString());
if (mes) params.set('mes', mes.toString());
if (contribuyenteId) params.set('contribuyenteId', contribuyenteId);
const response = await apiClient.get(`/cfdi/resumen?${params}`);
return response.data;
}
export interface CreateCfdiData {
uuid: string;
type: 'EMITIDO' | 'RECIBIDO';
serie?: string;
folio?: string;
status?: string;
fechaEmision: string;
rfcEmisor: string;
nombreEmisor: string;
rfcReceptor: string;
nombreReceptor: string;
subtotal: number;
subtotalMxn?: number;
descuento?: number;
descuentoMxn?: number;
total: number;
totalMxn?: number;
moneda?: string;
tipoCambio?: number;
tipoComprobante?: string;
metodoPago?: string;
formaPago?: string;
usoCfdi?: string;
ivaTraslado?: number;
ivaTrasladoMxn?: number;
isrRetencion?: number;
isrRetencionMxn?: number;
ivaRetencion?: number;
ivaRetencionMxn?: number;
}
export async function createCfdi(data: CreateCfdiData): Promise<Cfdi> {
const response = await apiClient.post<Cfdi>('/cfdi', data);
return response.data;
}
export interface BatchUploadResult {
message: string;
batchNumber: number;
totalBatches: number;
inserted: number;
duplicates: number;
errors: number;
errorMessages?: string[];
}
export async function createManyCfdis(
cfdis: CreateCfdiData[],
batchNumber?: number,
totalBatches?: number,
totalFiles?: number
): Promise<BatchUploadResult> {
const response = await apiClient.post<BatchUploadResult>('/cfdi/bulk', {
cfdis,
batchNumber: batchNumber || 1,
totalBatches: totalBatches || 1,
totalFiles: totalFiles || cfdis.length
});
return response.data;
}
export async function getCfdiConceptos(id: number | string): Promise<any[]> {
const response = await apiClient.get<any[]>(`/cfdi/${id}/conceptos`);
return response.data;
}
export async function deleteCfdi(id: string): Promise<void> {
await apiClient.delete(`/cfdi/${id}`);
}
export async function getCfdiXml(id: string): Promise<string> {
const response = await apiClient.get<string>(`/cfdi/${id}/xml`, {
responseType: 'text'
});
return response.data;
}
export interface EmisorReceptor {
rfc: string;
nombre: string;
}
export async function searchEmisores(search: string, contribuyenteId?: string): Promise<EmisorReceptor[]> {
if (search.length < 2) return [];
const params = new URLSearchParams({ search });
if (contribuyenteId) params.set('contribuyenteId', contribuyenteId);
const response = await apiClient.get<EmisorReceptor[]>(`/cfdi/emisores?${params}`);
return response.data;
}
export async function searchReceptores(search: string, contribuyenteId?: string): Promise<EmisorReceptor[]> {
if (search.length < 2) return [];
const params = new URLSearchParams({ search });
if (contribuyenteId) params.set('contribuyenteId', contribuyenteId);
const response = await apiClient.get<EmisorReceptor[]>(`/cfdi/receptores?${params}`);
return response.data;
}

View File

@@ -0,0 +1,79 @@
import axios from 'axios';
export const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api',
headers: {
'Content-Type': 'application/json',
},
});
apiClient.interceptors.request.use((config) => {
if (typeof window !== 'undefined') {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// Add viewing tenant header for admin users
const tenantViewStore = localStorage.getItem('horux-tenant-view');
if (tenantViewStore) {
try {
const { state } = JSON.parse(tenantViewStore);
if (state?.viewingTenantId) {
config.headers['X-View-Tenant'] = state.viewingTenantId;
}
} catch {
// Ignore parse errors
}
}
}
return config;
});
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// Rate limit hit. El backend envía { message } — lo preservamos para que los
// try/catch existentes (que leen err.response.data.message) muestren la razón
// correcta. Además mostramos un alert visible como fallback si nadie maneja.
if (error.response?.status === 429) {
const msg =
error.response?.data?.message ||
'Demasiadas solicitudes. Espera unos minutos e intenta de nuevo.';
if (typeof window !== 'undefined' && !originalRequest?._rateLimitHandled) {
originalRequest._rateLimitHandled = true;
console.warn('[rate-limit]', msg);
}
return Promise.reject(error);
}
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = localStorage.getItem('refreshToken');
if (refreshToken) {
const response = await axios.post(
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api'}/auth/refresh`,
{ refreshToken }
);
const { accessToken, refreshToken: newRefreshToken } = response.data;
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', newRefreshToken);
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return apiClient(originalRequest);
}
} catch {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);

View File

@@ -0,0 +1,58 @@
import { apiClient } from './client';
export interface ConciliacionCfdi {
id: number;
uuid: string;
type: string;
fechaEmision: string;
rfcEmisor: string;
nombreEmisor: string;
rfcReceptor: string;
nombreReceptor: string;
total: number;
totalMxn: number;
tipoComprobante: string | null;
montoPagoMxn: number;
montoMxn: number;
metodoPago: string | null;
conciliado: string | null;
idConciliacion: number | null;
conciliacion: {
id: number;
fechaDePago: string;
banco: string;
terminacionCuenta: string;
} | null;
}
export async function getCfdisConConciliacion(params: {
tipo: string;
fechaInicio?: string;
fechaFin?: string;
regimen?: string;
estado?: string;
contribuyenteId?: string;
}): Promise<ConciliacionCfdi[]> {
const q = new URLSearchParams();
q.set('tipo', params.tipo);
if (params.fechaInicio) q.set('fechaInicio', params.fechaInicio);
if (params.fechaFin) q.set('fechaFin', params.fechaFin);
if (params.regimen) q.set('regimen', params.regimen);
if (params.estado) q.set('estado', params.estado);
if (params.contribuyenteId) q.set('contribuyenteId', params.contribuyenteId);
const res = await apiClient.get<ConciliacionCfdi[]>(`/conciliacion?${q}`);
return res.data;
}
export async function conciliar(data: {
cfdiIds: number[];
fechaDePago: string;
idBanco: number;
}): Promise<{ count: number }> {
const res = await apiClient.post<{ count: number }>('/conciliacion', data);
return res.data;
}
export async function desconciliar(id: number): Promise<void> {
await apiClient.delete(`/conciliacion/${id}`);
}

View File

@@ -0,0 +1,78 @@
import { apiClient } from './client';
export interface DomicilioCsf {
codigoPostal?: string;
tipoVialidad?: string;
nombreVialidad?: string;
numeroExterior?: string;
numeroInterior?: string;
colonia?: string;
localidad?: string;
municipio?: string;
entidadFederativa?: string;
entreCalle?: string;
yCalle?: string;
}
export interface RegimenCsf {
nombre: string;
fechaInicio: string;
fechaFin?: string;
}
export interface ObligacionCsf {
descripcion: string;
descripcionVencimiento: string;
fechaInicio: string;
fechaFin?: string;
}
export interface ActividadEconomicaCsf {
orden: number;
descripcion: string;
porcentaje: number;
fechaInicio: string;
fechaFin?: string;
}
export interface ConstanciaDatos {
rfc: string;
curp?: string;
idCIF: string;
nombre?: string;
primerApellido?: string;
segundoApellido?: string;
razonSocial?: string;
nombreComercial?: string;
fechaInicioOperaciones: string;
estatusPadron: string;
fechaUltimoCambioEstado?: string;
lugarFechaEmision: string;
domicilio: DomicilioCsf;
actividadesEconomicas: ActividadEconomicaCsf[];
regimenes: RegimenCsf[];
obligaciones: ObligacionCsf[];
}
export interface Constancia {
id: number;
rfc: string;
idCif: string | null;
razonSocial: string | null;
estatusPadron: string | null;
fechaEmision: string | null;
datos: ConstanciaDatos;
fechaConsulta: string;
createdAt: string;
}
export const listConstancias = (contribuyenteId?: string) => {
const params = contribuyenteId ? `?contribuyenteId=${encodeURIComponent(contribuyenteId)}` : '';
return apiClient.get<Constancia[]>(`/documentos/constancias${params}`).then(r => r.data);
};
export const consultarConstancia = () =>
apiClient.post<Constancia>('/documentos/constancias/consultar').then(r => r.data);
export const descargarConstanciaPdf = (id: number) =>
apiClient.get(`/documentos/constancias/${id}/pdf`, { responseType: 'blob' }).then(r => r.data as Blob);

View File

@@ -0,0 +1,66 @@
import { apiClient } from './client';
export interface Contribuyente {
id: string;
tipo: string;
nombre: string;
identificador: string;
supervisorUserId: string | null;
active: boolean;
createdAt: string;
rfc: string;
regimenFiscal: string | null;
codigoPostal: string | null;
domicilio: Record<string, unknown> | null;
}
export interface CreateContribuyenteData {
rfc: string;
razonSocial: string;
regimenFiscal?: string;
codigoPostal?: string;
supervisorUserId?: string;
}
/**
* Resultado del ajuste automático de overage de Business Cloud al crear o
* desactivar un contribuyente. Si `action === 'created'`, el frontend debe
* abrir `paymentUrl` en una pestaña para que el usuario autorice el cobro.
*/
export interface OverageAdjustResult {
action: 'none' | 'created' | 'updated' | 'cancelled' | 'skipped';
overageCount: number;
paymentUrl?: string;
reason?: string;
}
export type ContribuyenteWithOverage = Contribuyente & { overage?: OverageAdjustResult };
export async function getContribuyentes(): Promise<{ data: Contribuyente[] }> {
const { data } = await apiClient.get('/contribuyentes');
return data;
}
export async function getContribuyente(id: string): Promise<Contribuyente> {
const { data } = await apiClient.get(`/contribuyentes/${id}`);
return data;
}
export async function createContribuyente(payload: CreateContribuyenteData): Promise<ContribuyenteWithOverage> {
const { data } = await apiClient.post('/contribuyentes', payload);
return data;
}
export async function updateContribuyente(id: string, payload: Partial<CreateContribuyenteData>): Promise<Contribuyente> {
const { data } = await apiClient.put(`/contribuyentes/${id}`, payload);
return data;
}
export async function deactivateContribuyente(id: string): Promise<{ message: string; overage?: OverageAdjustResult }> {
const { data } = await apiClient.delete(`/contribuyentes/${id}`);
return data;
}
export async function addClienteAcceso(contribuyenteId: string, userId: string): Promise<void> {
await apiClient.post(`/contribuyentes/${contribuyenteId}/cliente-acceso`, { userId });
}

View File

@@ -0,0 +1,41 @@
import { apiClient } from './client';
import type { KpiData, IngresosEgresosData, Alerta } from '@horux/shared';
export async function getKpis(fechaInicio: string, fechaFin: string, conciliacion?: boolean, contribuyenteId?: string | null): Promise<KpiData> {
const params = new URLSearchParams();
params.set('fechaInicio', fechaInicio);
params.set('fechaFin', fechaFin);
if (conciliacion) params.set('conciliacion', 'true');
if (contribuyenteId) params.set('contribuyenteId', contribuyenteId);
const response = await apiClient.get<KpiData>(`/dashboard/kpis?${params}`);
return response.data;
}
export async function getIngresosEgresos(año?: number, conciliacion?: boolean, contribuyenteId?: string | null): Promise<IngresosEgresosData[]> {
const params = new URLSearchParams();
if (año) params.set('año', año.toString());
if (conciliacion) params.set('conciliacion', 'true');
if (contribuyenteId) params.set('contribuyenteId', contribuyenteId);
const response = await apiClient.get<IngresosEgresosData[]>(`/dashboard/ingresos-egresos?${params}`);
return response.data;
}
export async function getRegimenesDelPeriodo(fechaInicio: string, fechaFin: string, conciliacion?: boolean, contribuyenteId?: string | null): Promise<{ clave: string; descripcion: string }[]> {
const params = new URLSearchParams();
params.set('fechaInicio', fechaInicio);
params.set('fechaFin', fechaFin);
if (conciliacion) params.set('conciliacion', 'true');
if (contribuyenteId) params.set('contribuyenteId', contribuyenteId);
const response = await apiClient.get<{ clave: string; descripcion: string }[]>(`/dashboard/regimenes-periodo?${params}`);
return response.data;
}
export async function getAlertas(limit = 5, contribuyenteId?: string | null): Promise<Alerta[]> {
const params = new URLSearchParams({ limit: String(limit) });
if (contribuyenteId) params.set('contribuyenteId', contribuyenteId);
const response = await apiClient.get<Alerta[]>(`/dashboard/alertas?${params}`);
return response.data;
}

View File

@@ -0,0 +1,87 @@
import { apiClient } from './client';
export type Impuesto = 'IVA' | 'ISR' | 'IEPS' | 'SUELDOS' | 'DIOT' | 'OTRO';
export type Periodicidad = 'mensual' | 'bimestral' | 'trimestral' | 'semestral' | 'anual';
export interface Declaracion {
id: number;
año: number;
mes: number;
tipo: 'normal' | 'complementaria';
periodicidad: Periodicidad;
impuestos: Impuesto[];
montoPago: number | null;
pdfFilename: string | null;
ligaPagoFilename: string | null;
pdfPagoFilename: string | null;
pagadoAt: string | null;
creadoPor: string | null;
notas: string | null;
createdAt: string;
updatedAt: string;
tieneLigaPago: boolean;
tienePagoPdf: boolean;
}
export interface CreateDeclaracionData {
año: number;
mes: number;
tipo: 'normal' | 'complementaria';
periodicidad?: Periodicidad;
impuestos: Impuesto[];
montoPago?: number;
pdfBase64: string;
pdfFilename: string;
ligaPagoBase64?: string;
ligaPagoFilename?: string;
notas?: string;
contribuyenteId?: string;
}
export const listDeclaraciones = (fechaDesde?: string, fechaHasta?: string, contribuyenteId?: string | null) => {
const params = new URLSearchParams();
if (fechaDesde) params.set('fechaDesde', fechaDesde);
if (fechaHasta) params.set('fechaHasta', fechaHasta);
if (contribuyenteId) params.set('contribuyenteId', contribuyenteId);
return apiClient.get<Declaracion[]>(`/documentos/declaraciones?${params}`).then(r => r.data);
};
export const createDeclaracion = (data: CreateDeclaracionData) =>
apiClient.post<{ declaracion: Declaracion; alertasResueltas: number }>('/documentos/declaraciones', data).then(r => r.data);
export const uploadComprobantePago = (id: number, pdfBase64: string, pdfFilename: string) =>
apiClient.post<{ declaracion: Declaracion; alertasResueltas: number }>(
`/documentos/declaraciones/${id}/comprobante-pago`,
{ pdfBase64, pdfFilename },
).then(r => r.data);
export const deleteDeclaracion = (id: number) =>
apiClient.delete(`/documentos/declaraciones/${id}`).then(r => r.data);
export const downloadDeclaracionPdf = (id: number, variant: 'declaracion' | 'liga' | 'pago') =>
apiClient.get(`/documentos/declaraciones/${id}/pdf/${variant}`, { responseType: 'blob' }).then(r => r.data as Blob);
export function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
// result format: "data:application/pdf;base64,..."
const base64 = result.split(',')[1] || '';
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
export function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}

Some files were not shown because too many files have changed in this diff Show More