- 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.
375 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|