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
206 lines
7.4 KiB
TypeScript
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>
|
|
);
|
|
}
|