Files
HoruxDespachosNuevo/apps/web/app/(dashboard)/onboarding/page.tsx
Horux Dev 9f11a0ba39 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
2026-05-09 21:56:42 +00:00

206 lines
7.4 KiB
TypeScript

'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 { dismissOnboarding } from '@/lib/api/auth';
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 setUser = useAuthStore((s) => s.setUser);
const { data: contribuyentes } = useContribuyentes();
const router = useRouter();
const [fielDone, setFielDone] = useState(false);
const [csdDone, setCsdDone] = useState(false);
const [dismissed, setDismissed] = 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: '/configuracion/sat',
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: '/configuracion/csd',
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;
// Auto-dismiss cuando todos los pasos requeridos están listos. Idempotente
// del lado backend, pero `dismissed` evita el round-trip si la página se
// re-renderiza (datos refetched).
useEffect(() => {
if (!allRequiredDone || dismissed || !user || user.onboardingDismissedAt) return;
setDismissed(true);
dismissOnboarding()
.then((res) => {
// Sync al store para que el siguiente login vaya directo al dashboard
// sin esperar a que el backend incremente loginCount > threshold.
setUser({ ...user, onboardingDismissedAt: res.onboardingDismissedAt });
})
.catch((err) => {
console.warn('[onboarding] Failed to mark as dismissed:', err);
setDismissed(false); // permite reintentar
});
}, [allRequiredDone, dismissed, user, setUser]);
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 !== '#' && (
<Button variant="outline" size="sm" className="flex items-center gap-1" asChild>
<Link href={step.href}>
Configurar <ArrowRight className="h-3 w-3" />
</Link>
</Button>
)}
</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>
);
}