feat(invitations): flujo de invitacion de clientes por email

Backend:
- Nuevo modelo Prisma ClientInvitation con token unico, expiracion
  y estados (pending/accepted/expired).
- Migracion: 20260511213955_add_client_invitations
- Service client-invitations.service.ts: crear invitacion,
  validar token, registrar desde invitacion (reutiliza logica
  de creacion de tenant + usuario de despacho.service).
- Controller + routes: POST /invitations/client (admin),
  GET /invitations/client/validate/:token (publico),
  POST /invitations/client/register/:token (publico),
  GET /invitations/client (admin).
- Email template client-invitation.ts con link a
  /invitacion/registro/{token}.
- Agregado sendClientInvitation a email.service.

Frontend:
- Pagina /invitacion/registro/[token] para que el invitado
  complete registro (nombre, password, despacho, RFC, perfil).
- Pagina /admin/invitar-cliente para que admin global envie
  invitaciones y vea el historial.
- Hooks useCreateInvitation, useValidateInvitationToken,
  useRegisterFromInvitation, useClientInvitations.
- API client lib/api/client-invitations.ts.

Infra:
- PM2 ecosystem.config.js: usa node --import tsx con
  kill_timeout aumentado a 15s para evitar EADDRINUSE.
- React Query retry=2 con delay exponencial para resiliencia.

Refs: docs/CAMBIOS-2026-05-09.md
This commit is contained in:
Horux Dev
2026-05-11 22:03:03 +00:00
parent 0a63593aab
commit 745bc8385b
12 changed files with 859 additions and 0 deletions

View File

@@ -0,0 +1,142 @@
'use client';
import { useState } from 'react';
import { Header } from '@/components/layouts/header';
import { useCreateInvitation, useClientInvitations } from '@/lib/hooks/use-client-invitations';
import { Button, Input, Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
import { format } from 'date-fns';
import { es } from 'date-fns/locale';
export default function InvitarClientePage() {
const [email, setEmail] = useState('');
const [nombreDespacho, setNombreDespacho] = useState('');
const [rfc, setRfc] = useState('');
const [message, setMessage] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null);
const createMut = useCreateInvitation();
const { data: invitations, isLoading } = useClientInvitations();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setMessage(null);
if (!email.trim()) {
setMessage({ kind: 'error', text: 'El email es requerido' });
return;
}
try {
await createMut.mutateAsync({ email, nombreDespacho, rfc });
setMessage({ kind: 'ok', text: 'Invitación enviada exitosamente' });
setEmail('');
setNombreDespacho('');
setRfc('');
} catch (err: any) {
setMessage({ kind: 'error', text: err.response?.data?.message || 'Error al enviar invitación' });
}
};
const statusBadge = (status: string) => {
const map: Record<string, string> = {
pending: 'bg-yellow-100 text-yellow-800',
accepted: 'bg-green-100 text-green-800',
expired: 'bg-gray-100 text-gray-800',
};
return (
<span className={`px-2 py-0.5 rounded text-xs font-medium ${map[status] || 'bg-gray-100'}`}>
{status === 'pending' ? 'Pendiente' : status === 'accepted' ? 'Aceptada' : 'Expirada'}
</span>
);
};
return (
<>
<Header title="Invitar cliente" />
<main className="p-6 space-y-6">
<Card>
<CardHeader>
<CardTitle>Nueva invitación</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4 max-w-md">
<div>
<label className="text-sm font-medium">Email del cliente *</label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="cliente@ejemplo.com"
required
/>
</div>
<div>
<label className="text-sm font-medium">Nombre del despacho (opcional)</label>
<Input
value={nombreDespacho}
onChange={(e) => setNombreDespacho(e.target.value)}
placeholder="Despacho Contable Pérez"
/>
</div>
<div>
<label className="text-sm font-medium">RFC (opcional)</label>
<Input
value={rfc}
onChange={(e) => setRfc(e.target.value.toUpperCase())}
placeholder="XAXX010101000"
/>
</div>
{message && (
<div className={`text-sm p-3 rounded ${message.kind === 'ok' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'}`}>
{message.text}
</div>
)}
<Button type="submit" disabled={createMut.isPending}>
{createMut.isPending ? 'Enviando...' : 'Enviar invitación'}
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Invitaciones enviadas</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
) : !invitations?.length ? (
<p className="text-sm text-muted-foreground">No hay invitaciones enviadas</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 font-medium">Email</th>
<th className="pb-2 font-medium">Despacho</th>
<th className="pb-2 font-medium">Estado</th>
<th className="pb-2 font-medium">Enviada</th>
<th className="pb-2 font-medium">Expira</th>
</tr>
</thead>
<tbody>
{invitations.map((inv) => (
<tr key={inv.id} className="border-b">
<td className="py-2">{inv.email}</td>
<td className="py-2">{inv.nombreDespacho || '-'}</td>
<td className="py-2">{statusBadge(inv.status)}</td>
<td className="py-2 text-muted-foreground">
{format(new Date(inv.sentAt), 'dd MMM yyyy', { locale: es })}
</td>
<td className="py-2 text-muted-foreground">
{format(new Date(inv.expiresAt), 'dd MMM yyyy', { locale: es })}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</main>
</>
);
}