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