Files
playzer/apps/frontend/src/features/complex/components/complex-users-section.tsx
Jose Selesan 6ad4fc9ee9 feat: add complex user invitation and management features
- Implemented complex member and invitation schemas using Zod for validation.
- Created new API endpoints for inviting, accepting, revoking, and canceling complex user invitations.
- Developed handlers for managing complex users, including listing, inviting, revoking, and canceling invitations.
- Added frontend components for displaying and managing complex users and invitations.
- Introduced session storage management for pending invitations.
- Integrated Gravatar for user avatars based on email.
- Created database migration for complex invitations table.
2026-04-22 15:30:04 -03:00

375 lines
13 KiB
TypeScript

import { Button } from '@/components/ui/button';
import { Field, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import {
ResponsiveDialog,
ResponsiveDialogClose,
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from '@/components/ui/responsive-dialog';
import { Separator } from '@/components/ui/separator';
import { UserAvatar } from '@/components/user-avatar';
import { apiClient } from '@/lib/api-client';
import type { ComplexUsersOverview } from '@repo/api-contract';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertCircle, Loader2, Mail, RotateCw, Trash2, UserPlus, UserX, Users } from 'lucide-react';
import { useEffect, useState } from 'react';
type ComplexUsersSectionProps = {
complexId: string | null;
canManageUsers: boolean;
};
function formatDate(value: string) {
return new Intl.DateTimeFormat('es-AR', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
}
function UsersSkeleton() {
return (
<section className="rounded-xl border bg-card p-5">
<div className="flex items-center gap-2">
<Users className="size-5 text-muted-foreground" />
<h3 className="text-lg font-medium">Usuarios</h3>
</div>
<Separator className="my-4" />
<p className="text-sm text-muted-foreground">Cargando usuarios...</p>
</section>
);
}
export function ComplexUsersSection({ complexId, canManageUsers }: ComplexUsersSectionProps) {
const queryClient = useQueryClient();
const [email, setEmail] = useState('');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [invitationToCancel, setInvitationToCancel] = useState<
ComplexUsersOverview['invitations'][number] | null
>(null);
useEffect(() => {
setEmail('');
setErrorMessage(null);
setSuccessMessage(null);
setInvitationToCancel(null);
}, [complexId]);
const usersQuery = useQuery({
queryKey: ['complex-users', complexId],
enabled: Boolean(complexId),
queryFn: () => apiClient.complexes.listUsers(complexId!),
});
const inviteMutation = useMutation({
mutationFn: (payload: { email: string }) => apiClient.complexes.inviteUser(complexId!, payload),
onSuccess: async () => {
setEmail('');
setErrorMessage(null);
setSuccessMessage('Invitación enviada correctamente.');
await queryClient.invalidateQueries({ queryKey: ['complex-users', complexId] });
},
onError: (error: Error) => {
setErrorMessage(error.message || 'No pudimos enviar la invitación.');
setSuccessMessage(null);
},
});
const revokeMutation = useMutation({
mutationFn: (userId: string) => apiClient.complexes.revokeUser(complexId!, { userId }),
onSuccess: async () => {
setErrorMessage(null);
setSuccessMessage('El usuario fue desvinculado del complejo.');
await queryClient.invalidateQueries({ queryKey: ['complex-users', complexId] });
},
onError: (error: Error) => {
setErrorMessage(error.message || 'No pudimos revocar el permiso.');
setSuccessMessage(null);
},
});
const resendInvitationMutation = useMutation({
mutationFn: (invitationId: string) =>
apiClient.complexes.resendInvitation(complexId!, invitationId),
onSuccess: async () => {
setErrorMessage(null);
setSuccessMessage('La invitación se reenvió correctamente.');
await queryClient.invalidateQueries({ queryKey: ['complex-users', complexId] });
},
onError: (error: Error) => {
setErrorMessage(error.message || 'No pudimos reenviar la invitación.');
setSuccessMessage(null);
},
});
const cancelInvitationMutation = useMutation({
mutationFn: (invitationId: string) =>
apiClient.complexes.cancelInvitation(complexId!, invitationId),
onSuccess: async () => {
setErrorMessage(null);
setSuccessMessage('La invitación fue cancelada.');
setInvitationToCancel(null);
await queryClient.invalidateQueries({ queryKey: ['complex-users', complexId] });
},
onError: (error: Error) => {
setErrorMessage(error.message || 'No pudimos cancelar la invitación.');
setSuccessMessage(null);
},
});
const handleInvite = async (event: React.FormEvent) => {
event.preventDefault();
setErrorMessage(null);
setSuccessMessage(null);
const trimmedEmail = email.trim();
if (!trimmedEmail) {
setErrorMessage('Ingresá un email.');
return;
}
inviteMutation.mutate({ email: trimmedEmail });
};
if (!complexId) {
return (
<section className="rounded-xl border bg-card p-5">
<div className="flex items-center gap-2">
<Users className="size-5 text-muted-foreground" />
<h3 className="text-lg font-medium">Usuarios</h3>
</div>
<Separator className="my-4" />
<p className="text-sm text-muted-foreground">
Seleccioná un complejo para ver sus usuarios.
</p>
</section>
);
}
if (usersQuery.isLoading) {
return <UsersSkeleton />;
}
if (usersQuery.isError || !usersQuery.data) {
return (
<section className="rounded-xl border bg-card p-5">
<div className="flex items-center gap-2">
<Users className="size-5 text-muted-foreground" />
<h3 className="text-lg font-medium">Usuarios</h3>
</div>
<Separator className="my-4" />
<p className="text-sm text-destructive">No se pudieron cargar los usuarios del complejo.</p>
</section>
);
}
const data: ComplexUsersOverview = usersQuery.data;
return (
<section className="rounded-xl border bg-card p-5">
<div className="flex items-center gap-2">
<Users className="size-5 text-muted-foreground" />
<h3 className="text-lg font-medium">Usuarios</h3>
</div>
<Separator className="my-4" />
{canManageUsers ? (
<form className="mb-6 rounded-lg border bg-muted/30 p-4" onSubmit={handleInvite}>
<div className="mb-3 flex items-center gap-2">
<UserPlus className="size-4 text-muted-foreground" />
<h4 className="text-sm font-medium">Invitar un usuario</h4>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<Field className="flex-1">
<FieldLabel htmlFor="invite-email">Email</FieldLabel>
<Input
id="invite-email"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="empleado@correo.com"
/>
</Field>
<div className="flex items-end">
<Button type="submit" disabled={inviteMutation.isPending}>
{inviteMutation.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}
Enviar invitación
</Button>
</div>
</div>
<p className="mt-2 text-xs text-muted-foreground">
La invitación se envía por email y el usuario quedará vinculado como EMPLOYEE al
aceptar.
</p>
</form>
) : (
<div className="mb-6 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
Solo un ADMIN puede invitar o desvincular usuarios.
</div>
)}
{successMessage && (
<p className="mb-4 text-sm text-green-600 dark:text-green-400">{successMessage}</p>
)}
{errorMessage && (
<p className="mb-4 flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="size-4" />
{errorMessage}
</p>
)}
<div className="space-y-6">
<div>
<h4 className="mb-3 text-sm font-medium">Miembros</h4>
<div className="space-y-2">
{data.members.map((member) => (
<div
key={member.userId}
className="flex flex-col gap-3 rounded-lg border px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<p className="font-medium">{member.fullName}</p>
<p className="text-sm text-muted-foreground">{member.email}</p>
<p className="text-xs text-muted-foreground">
Rol: {member.role} · Desde {formatDate(member.createdAt)}
</p>
</div>
<div className="flex items-center gap-2">
<UserAvatar
src={member.avatarUrl}
alt={member.fullName}
fallbackText={member.fullName}
size="default"
className="border"
/>
{canManageUsers && member.role === 'EMPLOYEE' && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => revokeMutation.mutate(member.userId)}
disabled={revokeMutation.isPending}
>
<UserX className="mr-2 size-4" />
Revocar
</Button>
)}
</div>
</div>
))}
</div>
</div>
<div>
<h4 className="mb-3 text-sm font-medium">Invitaciones pendientes</h4>
{data.invitations.length === 0 ? (
<p className="text-sm text-muted-foreground">No hay invitaciones pendientes.</p>
) : (
<div className="space-y-2">
{data.invitations.map((invitation) => (
<div
key={invitation.id}
className="flex flex-col gap-2 rounded-lg border px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<p className="font-medium">{invitation.email}</p>
<p className="text-xs text-muted-foreground">
Vence {formatDate(invitation.expiresAt)}
</p>
</div>
<div className="flex flex-col items-start gap-2 sm:items-end">
<span className="inline-flex items-center gap-2 text-xs uppercase tracking-wide text-muted-foreground">
<Mail className="size-3.5" />
Pendiente
</span>
{canManageUsers && (
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => resendInvitationMutation.mutate(invitation.id)}
disabled={resendInvitationMutation.isPending}
>
<RotateCw className="mr-2 size-4" />
Reenviar
</Button>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => setInvitationToCancel(invitation)}
disabled={cancelInvitationMutation.isPending}
>
<Trash2 className="mr-2 size-4" />
Cancelar
</Button>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
<ResponsiveDialog
open={Boolean(invitationToCancel)}
onOpenChange={(open) => {
if (!open) {
setInvitationToCancel(null);
}
}}
>
<ResponsiveDialogContent className="data-[variant=dialog]:max-w-md">
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>Cancelar invitación</ResponsiveDialogTitle>
<ResponsiveDialogDescription>
Esta acción va a invalidar el link de invitación y la persona ya no podrá aceptar la
invitación.
</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
<p className="text-sm text-muted-foreground">
{invitationToCancel
? `Se cancelará la invitación enviada a ${invitationToCancel.email}.`
: 'Se cancelará la invitación seleccionada.'}
</p>
<p className="text-sm text-muted-foreground">
Si querés volver a invitarlo, vas a poder generar una nueva invitación después.
</p>
<ResponsiveDialogFooter>
<ResponsiveDialogClose asChild>
<Button variant="outline">Volver</Button>
</ResponsiveDialogClose>
<Button
variant="destructive"
onClick={() => {
if (!invitationToCancel) return;
cancelInvitationMutation.mutate(invitationToCancel.id);
}}
disabled={cancelInvitationMutation.isPending}
>
{cancelInvitationMutation.isPending ? 'Cancelando...' : 'Cancelar invitación'}
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
</section>
);
}