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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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