Backend: - client-invitations.service.ts: funcion resendInvitation() que genera nuevo token, actualiza expiresAt y reenvia el email. - Controller + routes: POST /invitations/client/:id/resend Frontend: - API client + hook useResendInvitation con invalidacion de cache. - Pagina /admin/invitar-cliente: boton 'Reenviar' por cada invitacion pendiente en la tabla. Refs: docs/CAMBIOS-2026-05-09.md
157 lines
6.1 KiB
TypeScript
157 lines
6.1 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { Header } from '@/components/layouts/header';
|
|
import { useCreateInvitation, useClientInvitations, useResendInvitation } 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 resendMut = useResendInvitation();
|
|
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>
|
|
<th className="pb-2 font-medium"></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>
|
|
<td className="py-2">
|
|
{inv.status === 'pending' && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => resendMut.mutate(inv.id)}
|
|
disabled={resendMut.isPending}
|
|
>
|
|
{resendMut.isPending && resendMut.variables === inv.id ? 'Reenviando...' : 'Reenviar'}
|
|
</Button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</main>
|
|
</>
|
|
);
|
|
}
|