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
170 lines
6.9 KiB
TypeScript
170 lines
6.9 KiB
TypeScript
'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>
|
|
</>
|
|
);
|
|
}
|