Initial commit - Horux Despachos NL
This commit is contained in:
205
apps/web/app/(dashboard)/onboarding/page.tsx
Normal file
205
apps/web/app/(dashboard)/onboarding/page.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
'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: '/contribuyentes',
|
||||
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: '/contribuyentes',
|
||||
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 !== '#' && (
|
||||
<Link href={step.href}>
|
||||
<Button variant="outline" size="sm" className="flex items-center gap-1">
|
||||
Configurar <ArrowRight className="h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user